From 90aeb7b2cd9df63de3a663d354c55c425f8be7c3 Mon Sep 17 00:00:00 2001 From: Salim Kayal Date: Tue, 5 May 2026 17:27:20 +0200 Subject: [PATCH 01/24] feat: add resource pool members API specification --- .../renku_data_services/crc/api.spec.yaml | 233 +++++++++++ components/renku_data_services/crc/apispec.py | 361 +++++++++++------- 2 files changed, 448 insertions(+), 146 deletions(-) diff --git a/components/renku_data_services/crc/api.spec.yaml b/components/renku_data_services/crc/api.spec.yaml index 23d9fba15..66c0d45ce 100644 --- a/components/renku_data_services/crc/api.spec.yaml +++ b/components/renku_data_services/crc/api.spec.yaml @@ -772,6 +772,159 @@ paths: $ref: "#/components/responses/Error" tags: - resource_pools + /resource_pools/{resource_pool_id}/members: + get: + summary: Get all members of a resource pool + parameters: + - in: path + name: resource_pool_id + required: true + schema: + type: integer + responses: + "200": + description: The list of members + content: + "application/json": + schema: + $ref: "#/components/schemas/PoolMembers" + "404": + description: The resource pool does not exist + content: + "application/json": + schema: + $ref: "#/components/schemas/ErrorResponse" + default: + $ref: "#/components/responses/Error" + tags: + - resource_pools + post: + summary: Add members to a resource pool + parameters: + - in: path + name: resource_pool_id + required: true + schema: + type: integer + requestBody: + description: List of members + required: true + content: + "application/json": + schema: + $ref: "#/components/schemas/PoolMembers" + responses: + "201": + description: The members were added + content: + "application/json": + schema: + $ref: "#/components/schemas/PoolMembers" + "404": + description: The resource pool or a member does not exist + content: + "application/json": + schema: + $ref: "#/components/schemas/ErrorResponse" + default: + $ref: "#/components/responses/Error" + tags: + - resource_pools + put: + summary: Set the members of a resource pool + parameters: + - in: path + name: resource_pool_id + required: true + schema: + type: integer + requestBody: + description: List of members + required: true + content: + "application/json": + schema: + $ref: "#/components/schemas/PoolMembers" + responses: + "200": + description: The members were set + content: + "application/json": + schema: + $ref: "#/components/schemas/PoolMembers" + "404": + description: The resource pool or a member does not exist + content: + "application/json": + schema: + $ref: "#/components/schemas/ErrorResponse" + default: + $ref: "#/components/responses/Error" + tags: + - resource_pools + /resource_pools/{resource_pool_id}/members/{member_type}/{member_id}: + get: + summary: Check if a specific member belongs to a resource pool + parameters: + - in: path + name: resource_pool_id + required: true + schema: + type: integer + - in: path + name: member_type + required: true + schema: + type: string + enum: [user, group, project] + - in: path + name: member_id + required: true + schema: + type: string + responses: + "200": + description: The member belongs to the resource pool + content: + "application/json": + schema: + $ref: "#/components/schemas/PoolMember" + "404": + description: The member does not belong to the resource pool, or the resource pool or member do not exist + content: + "application/json": + schema: + $ref: "#/components/schemas/ErrorResponse" + default: + $ref: "#/components/responses/Error" + tags: + - resource_pools + delete: + summary: Remove a specific member from a resource pool + parameters: + - in: path + name: resource_pool_id + required: true + schema: + type: integer + - in: path + name: member_type + required: true + schema: + type: string + enum: [user, group, project] + - in: path + name: member_id + required: true + schema: + type: string + responses: + "204": + description: The member was removed or it was not part of the pool + default: + $ref: "#/components/responses/Error" + tags: + - resource_pools /resource_pools/{resource_pool_id}/quota: get: summary: Get the quota associated with the resource pool @@ -1577,6 +1730,86 @@ components: items: $ref: "#/components/schemas/PoolUserWithId" uniqueItems: true + PoolMemberUser: + type: object + additionalProperties: false + required: + - member_type + - id + - relation + properties: + member_type: + type: string + enum: [user] + id: + $ref: "#/components/schemas/UserId" + relation: + type: string + enum: [viewer, prohibited] + example: + member_type: user + id: "some-random-keycloak-id" + relation: viewer + PoolMemberGroup: + type: object + additionalProperties: false + required: + - member_type + - id + - relation + properties: + member_type: + type: string + enum: [group] + id: + type: string + description: Group id (ULID) + example: "01ARZ3NDEKTSV4RRFFQ69G5FAV" + relation: + type: string + enum: [group_viewer] + example: + member_type: group + id: "01ARZ3NDEKTSV4RRFFQ69G5FAV" + relation: group_viewer + PoolMemberProject: + type: object + additionalProperties: false + required: + - member_type + - id + - relation + properties: + member_type: + type: string + enum: [project] + id: + type: string + description: Project id (ULID) + example: "01ARZ3NDEKTSV4RRFFQ69G5FAW" + relation: + type: string + enum: [project_viewer] + example: + member_type: project + id: "01ARZ3NDEKTSV4RRFFQ69G5FAW" + relation: project_viewer + PoolMember: + oneOf: + - $ref: "#/components/schemas/PoolMemberUser" + - $ref: "#/components/schemas/PoolMemberGroup" + - $ref: "#/components/schemas/PoolMemberProject" + discriminator: + propertyName: member_type + mapping: + user: "#/components/schemas/PoolMemberUser" + group: "#/components/schemas/PoolMemberGroup" + project: "#/components/schemas/PoolMemberProject" + PoolMembers: + type: array + items: + $ref: "#/components/schemas/PoolMember" + uniqueItems: true QuotaPatch: type: object additionalProperties: false diff --git a/components/renku_data_services/crc/apispec.py b/components/renku_data_services/crc/apispec.py index 476bf94bd..ceecb220b 100644 --- a/components/renku_data_services/crc/apispec.py +++ b/components/renku_data_services/crc/apispec.py @@ -1,16 +1,63 @@ # generated by datamodel-codegen: # filename: api.spec.yaml -# timestamp: 2026-05-08T11:37:11+00:00 +# timestamp: 2026-05-13T07:18:06+00:00 from __future__ import annotations -from enum import Enum -from typing import List, Optional, Union +from enum import StrEnum +from typing import Annotated, Literal from pydantic import ConfigDict, Field, RootModel from renku_data_services.crc.apispec_base import BaseAPISpec +class MemberType(StrEnum): + user = "user" + + +class Relation(StrEnum): + viewer = "viewer" + prohibited = "prohibited" + + +class MemberType1(StrEnum): + group = "group" + + +class Relation1(StrEnum): + group_viewer = "group_viewer" + + +class PoolMemberGroup(BaseAPISpec): + model_config = ConfigDict( + extra="forbid", + ) + member_type: Literal["group"] = "group" + id: str = Field( + ..., description="Group id (ULID)", examples=["01ARZ3NDEKTSV4RRFFQ69G5FAV"] + ) + relation: Relation1 + + +class MemberType2(StrEnum): + project = "project" + + +class Relation2(StrEnum): + project_viewer = "project_viewer" + + +class PoolMemberProject(BaseAPISpec): + model_config = ConfigDict( + extra="forbid", + ) + member_type: Literal["project"] = "project" + id: str = Field( + ..., description="Project id (ULID)", examples=["01ARZ3NDEKTSV4RRFFQ69G5FAW"] + ) + relation: Relation2 + + class Version(BaseAPISpec): version: str @@ -24,26 +71,25 @@ class IntegerId(RootModel[int]): ) -class IntegerIds(RootModel[List[IntegerId]]): - root: List[IntegerId] = Field(..., examples=[[1, 3, 5]], min_length=1) +class IntegerIds(RootModel[list[IntegerId]]): + root: list[IntegerId] = Field(..., examples=[[1, 3, 5]], min_length=1) -class Kind(Enum): +class Kind(StrEnum): firecrest = "firecrest" -class Kind1(Enum): +class Kind1(StrEnum): runai = "runai" class RemoteConfigurationPatchReset(BaseAPISpec): - pass model_config = ConfigDict( extra="forbid", ) -class Kind2(Enum): +class Kind2(StrEnum): firecrest = "firecrest" @@ -51,32 +97,32 @@ class RemoteConfigurationFirecrestPatch(BaseAPISpec): model_config = ConfigDict( extra="forbid", ) - kind: Optional[Kind2] = Field( + kind: Kind2 | None = Field( None, description="Kind of remote resource pool", examples=["firecrest"] ) - provider_id: Optional[str] = Field( + provider_id: str | None = Field( None, description="The ID of a provider (see oauth2 section).\nThis is used to allow seamless authentication using Renku integrations.\n", examples=["my-provider"], ) - api_url: Optional[str] = Field( + api_url: str | None = Field( None, description="The base URL of the FirecREST API", examples=["https://api.cscs.ch/hpc/firecrest/v2"], ) - system_name: Optional[str] = Field( + system_name: str | None = Field( None, description="The name of the system to use with the FirecREST API", examples=["eiger"], ) - partition: Optional[str] = Field( + partition: str | None = Field( None, description="The partition to use when submitting jobs", examples=["normal"], ) -class Kind3(Enum): +class Kind3(StrEnum): runai = "runai" @@ -84,15 +130,15 @@ class RemoteConfigurationRunaiPatch(BaseAPISpec): model_config = ConfigDict( extra="forbid", ) - kind: Optional[Kind3] = Field( + kind: Kind3 | None = Field( None, description="Kind of remote resource pool", examples=["runai"] ) - base_url: Optional[str] = Field( + base_url: str | None = Field( None, description="The base URL of the Runai server", examples=["https://sdsc.run.ai"], ) - provider_id: Optional[str] = Field( + provider_id: str | None = Field( None, description="The ID of a provider (see oauth2 section).\nThis is used to allow seamless authentication using Renku integrations.\n", examples=["my-provider"], @@ -125,29 +171,30 @@ class NodeAffinity(BaseAPISpec): required_during_scheduling: bool = False -class NodeAffinityListResponse(RootModel[List[NodeAffinity]]): - root: List[NodeAffinity] = Field( +class NodeAffinityListResponse(RootModel[list[NodeAffinity] | None]): + root: list[NodeAffinity] | None = Field( [], description="A list of k8s labels used for tolerations and/or node affinity", examples=[[{"key": "test-label-1", "required_during_scheduling": False}]], min_length=0, + validate_default=True, ) -class RuntimePlatform(Enum): +class RuntimePlatform(StrEnum): linux_amd64 = "linux/amd64" linux_arm64 = "linux/arm64" class Error(BaseAPISpec): code: int = Field(..., examples=[1404], gt=0) - detail: Optional[str] = Field( + detail: str | None = Field( None, examples=["A more detailed optional message showing what the problem was"] ) message: str = Field( ..., examples=["Something went wrong - please try again later"] ) - trace_id: Optional[str] = Field( + trace_id: str | None = Field( None, description="Sentry trace ID for linking to corresponding log entries", examples=["ac93950e9e114a55c67fb8e5ef519bbe"], @@ -158,13 +205,12 @@ class ErrorResponse(BaseAPISpec): error: Error -class Protocol(Enum): +class Protocol(StrEnum): http = "http" https = "https" class IngressAnnotations(BaseAPISpec): - pass model_config = ConfigDict( extra="allow", ) @@ -195,14 +241,14 @@ class ResourcePoolsParams(BaseAPISpec): class ResourcePoolsGetParametersQuery(BaseAPISpec): - resource_pools_params: Optional[ResourcePoolsParams] = None + resource_pools_params: ResourcePoolsParams | None = None class ResourceClassParams(BaseAPISpec): model_config = ConfigDict( extra="forbid", ) - name: Optional[str] = Field( + name: str | None = Field( None, description="A name for a specific resource", examples=["the name of a resource"], @@ -211,14 +257,14 @@ class ResourceClassParams(BaseAPISpec): class ResourcePoolsResourcePoolIdClassesGetParametersQuery(BaseAPISpec): - resource_class_params: Optional[ResourceClassParams] = None + resource_class_params: ResourceClassParams | None = None class UserResourceParams(BaseAPISpec): model_config = ConfigDict( extra="forbid", ) - name: Optional[str] = Field( + name: str | None = Field( None, description="A name for a specific resource", examples=["the name of a resource"], @@ -227,7 +273,7 @@ class UserResourceParams(BaseAPISpec): class UsersUserIdResourcePoolsGetParametersQuery(BaseAPISpec): - user_resource_params: Optional[UserResourceParams] = None + user_resource_params: UserResourceParams | None = None class Cluster(BaseAPISpec): @@ -252,11 +298,11 @@ class Cluster(BaseAPISpec): ) session_port: int = Field(..., ge=0, le=65536) session_path: str - session_ingress_class_name: Optional[str] = Field(None, max_length=256) + session_ingress_class_name: str | None = Field(None, max_length=256) session_ingress_annotations: IngressAnnotations - session_tls_secret_name: Optional[str] = Field(None, max_length=256) - session_storage_class: Optional[str] = Field(None, max_length=256) - service_account_name: Optional[str] = Field( + session_tls_secret_name: str | None = Field(None, max_length=256) + session_storage_class: str | None = Field(None, max_length=256) + service_account_name: str | None = Field( None, description="A name of any K8s resource (i.e. Pod, Service, Secret, etc.).\nThis is pattern imposes the stricter rules applied to some resource type names\nthat need to follow DNS label standard and therefore can be used for all types.\nLooser rules can be applied to a smaller subset of resource types.\n", examples=["some-k8s-resource"], @@ -271,29 +317,29 @@ class ClusterPatch(BaseAPISpec): model_config = ConfigDict( extra="forbid", ) - name: Optional[str] = Field( + name: str | None = Field( None, description="A name for a specific resource", examples=["the name of a resource"], min_length=5, ) - config_name: Optional[str] = Field( + config_name: str | None = Field( None, description="The name of the Kubernetes configuration to use to connect to the remote cluster. This is currently used to find a file named `/`.\n\nThis configuration is expected to have a default namespace defined. It will be used for all remote operations requiring a namespace, as well for namespaced objects.\n", examples=["a-remote-cluster.yaml"], pattern="^[a-zA-Z0-9._-]+[.]yaml$", ) - session_protocol: Optional[Protocol] = None - session_host: Optional[str] = Field( + session_protocol: Protocol | None = None + session_host: str | None = Field( None, max_length=256, pattern="^([0-9a-zA-Z:_-]+[.])*[0-9a-zA-Z:_-]+$" ) - session_port: Optional[int] = Field(None, ge=0, le=65536) - session_path: Optional[str] = None - session_ingress_class_name: Optional[str] = Field(None, max_length=256) - session_ingress_annotations: Optional[IngressAnnotations] = None - session_tls_secret_name: Optional[str] = Field(None, max_length=256) - session_storage_class: Optional[str] = Field(None, max_length=256) - service_account_name: Optional[str] = Field( + session_port: int | None = Field(None, ge=0, le=65536) + session_path: str | None = None + session_ingress_class_name: str | None = Field(None, max_length=256) + session_ingress_annotations: IngressAnnotations | None = None + session_tls_secret_name: str | None = Field(None, max_length=256) + session_storage_class: str | None = Field(None, max_length=256) + service_account_name: str | None = Field( None, description="A name of any K8s resource (i.e. Pod, Service, Secret, etc.).\nThis is pattern imposes the stricter rules applied to some resource type names\nthat need to follow DNS label standard and therefore can be used for all types.\nLooser rules can be applied to a smaller subset of resource types. An empty\nstring indicates that the value should be removed if present in the DB.\n", examples=["some-k8s-resource"], @@ -301,7 +347,7 @@ class ClusterPatch(BaseAPISpec): min_length=0, pattern="^[a-z0-9][a-z0-9-]*[a-z0-9]$", ) - session_ingress_use_default_cluster_tls_cert: Optional[bool] = None + session_ingress_use_default_cluster_tls_cert: bool | None = None class ClusterWithId(BaseAPISpec): @@ -333,16 +379,16 @@ class ClusterWithId(BaseAPISpec): ) session_port: int = Field(..., ge=0, le=65536) session_path: str - session_ingress_class_name: Optional[str] = Field(None, max_length=256) + session_ingress_class_name: str | None = Field(None, max_length=256) session_ingress_annotations: IngressAnnotations - session_tls_secret_name: Optional[str] = Field(None, max_length=256) - session_storage_class: Optional[str] = Field(None, max_length=256) - service_account_name: Optional[str] = None + session_tls_secret_name: str | None = Field(None, max_length=256) + session_storage_class: str | None = Field(None, max_length=256) + service_account_name: str | None = None session_ingress_use_default_cluster_tls_cert: bool -class ClustersWithId(RootModel[List[ClusterWithId]]): - root: List[ClusterWithId] +class ClustersWithId(RootModel[list[ClusterWithId]]): + root: list[ClusterWithId] class Cluster1(BaseAPISpec): @@ -371,25 +417,57 @@ class PoolUserWithId(BaseAPISpec): ) -class PoolUsersWithId(RootModel[List[PoolUserWithId]]): - root: List[PoolUserWithId] +class PoolUsersWithId(RootModel[list[PoolUserWithId]]): + root: list[PoolUserWithId] + + +class PoolMemberUser(BaseAPISpec): + model_config = ConfigDict( + extra="forbid", + ) + member_type: Literal["user"] = "user" + id: str = Field( + ..., + description="Keycloak user ID", + examples=["f74a228b-1790-4276-af5f-25c2424e9b0c"], + pattern="^[A-Za-z0-9]{1}[A-Za-z0-9-]+$", + ) + relation: Relation + + +class PoolMembers( + RootModel[ + list[ + Annotated[ + PoolMemberUser | PoolMemberGroup | PoolMemberProject, + Field(discriminator="member_type"), + ] + ] + ] +): + root: list[ + Annotated[ + PoolMemberUser | PoolMemberGroup | PoolMemberProject, + Field(discriminator="member_type"), + ] + ] class QuotaPatch(BaseAPISpec): model_config = ConfigDict( extra="forbid", ) - cpu: Optional[float] = Field( + cpu: float | None = Field( None, description="Number of cpu cores", examples=[10], gt=0.0 ) - memory: Optional[int] = Field( + memory: int | None = Field( None, description="Number of gigabytes of memory", examples=[4], gt=0, le=9223372036854775807, ) - gpu: Optional[int] = Field( + gpu: int | None = Field( None, description="Number of GPUs", examples=[8], ge=0, le=9223372036854775807 ) @@ -432,7 +510,7 @@ class QuotaWithOptionalId(BaseAPISpec): gpu: int = Field( ..., description="Number of GPUs", examples=[8], ge=0, le=9223372036854775807 ) - id: Optional[str] = Field( + id: str | None = Field( None, description="A name for a specific resource", examples=["the name of a resource"], @@ -447,7 +525,7 @@ class RemoteConfigurationFirecrest(BaseAPISpec): kind: Kind = Field( ..., description="Kind of remote resource pool", examples=["firecrest"] ) - provider_id: Optional[str] = Field( + provider_id: str | None = Field( None, description="The ID of a provider (see oauth2 section).\nThis is used to allow seamless authentication using Renku integrations.\n", examples=["my-provider"], @@ -462,7 +540,7 @@ class RemoteConfigurationFirecrest(BaseAPISpec): description="The name of the system to use with the FirecREST API", examples=["eiger"], ) - partition: Optional[str] = Field( + partition: str | None = Field( None, description="The partition to use when submitting jobs", examples=["normal"], @@ -481,7 +559,7 @@ class RemoteConfigurationRunai(BaseAPISpec): description="The base URL of the Runai server", examples=["https://sdsc.run.ai"], ) - provider_id: Optional[str] = Field( + provider_id: str | None = Field( None, description="The ID of a provider (see oauth2 section).\nThis is used to allow seamless authentication using Renku integrations.\n", examples=["my-provider"], @@ -492,51 +570,51 @@ class ResourceClassProperties(BaseAPISpec): model_config = ConfigDict( extra="forbid", ) - name: Optional[str] = Field( + name: str | None = Field( None, description="A name for a specific resource", examples=["the name of a resource"], min_length=5, ) - default: Optional[bool] = Field( + default: bool | None = Field( None, description="A default selection for resource classes or resource pools", examples=[False], ) - cpu: Optional[float] = Field( + cpu: float | None = Field( None, description="Number of cpu cores", examples=[10], gt=0.0 ) - memory: Optional[int] = Field( + memory: int | None = Field( None, description="Number of gigabytes of memory", examples=[4], gt=0, le=9223372036854775807, ) - gpu: Optional[int] = Field( + gpu: int | None = Field( None, description="Number of GPUs", examples=[8], ge=0, le=9223372036854775807 ) - max_storage: Optional[int] = Field( + max_storage: int | None = Field( None, description="Number of gigabytes of storage", examples=[100], gt=0, le=9223372036854775807, ) - default_storage: Optional[int] = Field( + default_storage: int | None = Field( None, description="Number of gigabytes of storage", examples=[100], gt=0, le=9223372036854775807, ) - tolerations: Optional[List[K8sLabel]] = Field( + tolerations: list[K8sLabel] | None = Field( None, description="A list of k8s labels used for tolerations", examples=[["test-label-1"]], min_length=0, ) - node_affinities: Optional[List[NodeAffinity]] = Field( + node_affinities: list[NodeAffinity] | None = Field( None, description="A list of k8s labels used for tolerations and/or node affinity", examples=[[{"key": "test-label-1", "required_during_scheduling": False}]], @@ -584,13 +662,13 @@ class ResourceClass(BaseAPISpec): gt=0, le=9223372036854775807, ) - tolerations: Optional[List[K8sLabel]] = Field( + tolerations: list[K8sLabel] | None = Field( None, description="A list of k8s labels used for tolerations", examples=[["test-label-1"]], min_length=0, ) - node_affinities: Optional[List[NodeAffinity]] = Field( + node_affinities: list[NodeAffinity] | None = Field( None, description="A list of k8s labels used for tolerations and/or node affinity", examples=[[{"key": "test-label-1", "required_during_scheduling": False}]], @@ -651,13 +729,13 @@ class ResourceClassWithId(BaseAPISpec): gt=0, le=9223372036854775807, ) - tolerations: Optional[List[K8sLabel]] = Field( + tolerations: list[K8sLabel] | None = Field( None, description="A list of k8s labels used for tolerations", examples=[["test-label-1"]], min_length=0, ) - node_affinities: Optional[List[NodeAffinity]] = Field( + node_affinities: list[NodeAffinity] | None = Field( None, description="A list of k8s labels used for tolerations and/or node affinity", examples=[[{"key": "test-label-1", "required_during_scheduling": False}]], @@ -672,14 +750,14 @@ class ResourceClassWithId(BaseAPISpec): class ResourceClassWithIdFiltered(ResourceClassWithId): - matching: Optional[bool] = None - usage_hours_remaining: Optional[float] = Field( + matching: bool | None = None + usage_hours_remaining: float | None = Field( None, description="Number of resource hours remaining for the user", examples=[3.141], ge=0.0, ) - usage_hours_total: Optional[float] = Field( + usage_hours_total: float | None = Field( None, description="Total number of resource hours available to the user", examples=[10], @@ -687,8 +765,8 @@ class ResourceClassWithIdFiltered(ResourceClassWithId): ) -class ResourceClassesWithIdResponse(RootModel[List[ResourceClassWithId]]): - root: List[ResourceClassWithId] = Field( +class ResourceClassesWithIdResponse(RootModel[list[ResourceClassWithId]]): + root: list[ResourceClassWithId] = Field( ..., examples=[ [ @@ -721,8 +799,8 @@ class ResourcePoolPatch(BaseAPISpec): model_config = ConfigDict( extra="forbid", ) - quota: Optional[QuotaPatch] = None - classes: Optional[List[ResourceClassPatchWithId]] = Field( + quota: QuotaPatch | None = None + classes: list[ResourceClassPatchWithId] | None = Field( None, examples=[ [ @@ -732,66 +810,65 @@ class ResourcePoolPatch(BaseAPISpec): ], min_length=1, ) - name: Optional[str] = Field( + name: str | None = Field( None, description="A name for a specific resource", examples=["the name of a resource"], min_length=5, ) - public: Optional[bool] = Field( + public: bool | None = Field( None, description="A resource pool whose classes can be accessed by anyone", examples=[False], ) - default: Optional[bool] = Field( + default: bool | None = Field( None, description="A default selection for resource classes or resource pools", examples=[False], ) - remote: Optional[ - Union[ - RemoteConfigurationPatchReset, - RemoteConfigurationFirecrestPatch, - RemoteConfigurationRunaiPatch, - ] - ] = Field( + remote: ( + RemoteConfigurationPatchReset + | RemoteConfigurationFirecrestPatch + | RemoteConfigurationRunaiPatch + | None + ) = Field( None, description="Patch for the configuration used by to start sessions remotely\n", ) - idle_threshold: Optional[int] = Field( + idle_threshold: int | None = Field( None, description="A threshold in seconds after which a session gets hibernated (0 means no threshold)", ge=0, le=2147483647, ) - hibernation_threshold: Optional[int] = Field( + hibernation_threshold: int | None = Field( None, description="A threshold in seconds after which a session gets culled/deleted (0 means no threshold)", ge=0, le=2147483647, ) - hibernation_warning_period: Optional[int] = Field( + hibernation_warning_period: int | None = Field( None, description="A duration in seconds, lower than `HibernationThreshold`,\nwhich indicates when to let the user know about a session that\nis going to be hibernated. If `0` is specified, some default\nvalue will be chosen. If no `HibernationThreshold` is defined,\nthis value is ignored.\n", ge=0, le=2147483647, ) - cluster_id: Optional[str] = Field( + cluster_id: str | None = Field( None, description="ULID identifier", max_length=26, min_length=26, pattern="^[0-7][0-9A-HJKMNP-TV-Z]{25}$", ) - platform: Optional[RuntimePlatform] = None + platform: RuntimePlatform | None = None class ResourcePool(BaseAPISpec): model_config = ConfigDict( extra="forbid", ) - quota: Optional[QuotaWithOptionalId] = None - classes: List[ResourceClass] + quota: QuotaWithOptionalId | None = None + classes: list[ResourceClass] name: str = Field( ..., description="A name for a specific resource", @@ -808,46 +885,44 @@ class ResourcePool(BaseAPISpec): description="A default selection for resource classes or resource pools", examples=[False], ) - remote: Optional[Union[RemoteConfigurationFirecrest, RemoteConfigurationRunai]] = ( - Field( - None, - description="The configuration used by Renku to start sessions remotely.\nIf this field is present, the corresponding resource pool starts remote sessions.\n", - ) + remote: RemoteConfigurationFirecrest | RemoteConfigurationRunai | None = Field( + None, + description="The configuration used by Renku to start sessions remotely.\nIf this field is present, the corresponding resource pool starts remote sessions.\n", ) - idle_threshold: Optional[int] = Field( + idle_threshold: int | None = Field( None, description="A threshold in seconds after which a session gets hibernated (0 means no threshold)", ge=0, le=2147483647, ) - hibernation_threshold: Optional[int] = Field( + hibernation_threshold: int | None = Field( None, description="A threshold in seconds after which a session gets culled/deleted (0 means no threshold)", ge=0, le=2147483647, ) - hibernation_warning_period: Optional[int] = Field( + hibernation_warning_period: int | None = Field( None, description="A duration in seconds, lower than `HibernationThreshold`,\nwhich indicates when to let the user know about a session that\nis going to be hibernated. If `0` is specified, some default\nvalue will be chosen. If no `HibernationThreshold` is defined,\nthis value is ignored.\n", ge=0, le=2147483647, ) - cluster_id: Optional[str] = Field( + cluster_id: str | None = Field( None, description="ULID identifier", max_length=26, min_length=26, pattern="^[0-7][0-9A-HJKMNP-TV-Z]{25}$", ) - platform: Optional[RuntimePlatform] = None + platform: RuntimePlatform | None = None class ResourcePoolPut(BaseAPISpec): model_config = ConfigDict( extra="forbid", ) - quota: Optional[QuotaWithId] = None - classes: List[ResourceClassWithId] = Field( + quota: QuotaWithId | None = None + classes: list[ResourceClassWithId] = Field( ..., examples=[ [ @@ -890,31 +965,29 @@ class ResourcePoolPut(BaseAPISpec): description="A default selection for resource classes or resource pools", examples=[False], ) - remote: Optional[Union[RemoteConfigurationFirecrest, RemoteConfigurationRunai]] = ( - Field( - None, - description="The configuration used by Renku to start sessions remotely.\nIf this field is present, the corresponding resource pool starts remote sessions.\n", - ) + remote: RemoteConfigurationFirecrest | RemoteConfigurationRunai | None = Field( + None, + description="The configuration used by Renku to start sessions remotely.\nIf this field is present, the corresponding resource pool starts remote sessions.\n", ) - idle_threshold: Optional[int] = Field( + idle_threshold: int | None = Field( None, description="A threshold in seconds after which a session gets hibernated (0 means no threshold)", ge=0, le=2147483647, ) - hibernation_threshold: Optional[int] = Field( + hibernation_threshold: int | None = Field( None, description="A threshold in seconds after which a session gets culled/deleted (0 means no threshold)", ge=0, le=2147483647, ) - hibernation_warning_period: Optional[int] = Field( + hibernation_warning_period: int | None = Field( None, description="A duration in seconds, lower than `HibernationThreshold`,\nwhich indicates when to let the user know about a session that\nis going to be hibernated. If `0` is specified, some default\nvalue will be chosen. If no `HibernationThreshold` is defined,\nthis value is ignored.\n", ge=0, le=2147483647, ) - cluster_id: Optional[str] = Field( + cluster_id: str | None = Field( None, description="ULID identifier", max_length=26, @@ -928,8 +1001,8 @@ class ResourcePoolWithId(BaseAPISpec): model_config = ConfigDict( extra="forbid", ) - quota: Optional[QuotaWithId] = None - classes: List[ResourceClassWithId] + quota: QuotaWithId | None = None + classes: list[ResourceClassWithId] name: str = Field( ..., description="A name for a specific resource", @@ -952,31 +1025,29 @@ class ResourcePoolWithId(BaseAPISpec): description="A default selection for resource classes or resource pools", examples=[False], ) - remote: Optional[Union[RemoteConfigurationFirecrest, RemoteConfigurationRunai]] = ( - Field( - None, - description="The configuration used by Renku to start sessions remotely.\nIf this field is present, the corresponding resource pool starts remote sessions.\n", - ) + remote: RemoteConfigurationFirecrest | RemoteConfigurationRunai | None = Field( + None, + description="The configuration used by Renku to start sessions remotely.\nIf this field is present, the corresponding resource pool starts remote sessions.\n", ) - idle_threshold: Optional[int] = Field( + idle_threshold: int | None = Field( None, description="A threshold in seconds after which a session gets hibernated (0 means no threshold)", ge=0, le=2147483647, ) - hibernation_threshold: Optional[int] = Field( + hibernation_threshold: int | None = Field( None, description="A threshold in seconds after which a session gets culled/deleted (0 means no threshold)", ge=0, le=2147483647, ) - hibernation_warning_period: Optional[int] = Field( + hibernation_warning_period: int | None = Field( None, description="A duration in seconds, lower than `HibernationThreshold`,\nwhich indicates when to let the user know about a session that\nis going to be hibernated. If `0` is specified, some default\nvalue will be chosen. If no `HibernationThreshold` is defined,\nthis value is ignored.\n", ge=0, le=2147483647, ) - cluster: Optional[Cluster1] = None + cluster: Cluster1 | None = None platform: RuntimePlatform @@ -984,8 +1055,8 @@ class ResourcePoolWithIdFiltered(BaseAPISpec): model_config = ConfigDict( extra="forbid", ) - quota: Optional[QuotaWithId] = None - classes: List[ResourceClassWithIdFiltered] + quota: QuotaWithId | None = None + classes: list[ResourceClassWithIdFiltered] name: str = Field( ..., description="A name for a specific resource", @@ -1008,31 +1079,29 @@ class ResourcePoolWithIdFiltered(BaseAPISpec): description="A default selection for resource classes or resource pools", examples=[False], ) - remote: Optional[Union[RemoteConfigurationFirecrest, RemoteConfigurationRunai]] = ( - Field( - None, - description="The configuration used by Renku to start sessions remotely.\nIf this field is present, the corresponding resource pool starts remote sessions.\n", - ) + remote: RemoteConfigurationFirecrest | RemoteConfigurationRunai | None = Field( + None, + description="The configuration used by Renku to start sessions remotely.\nIf this field is present, the corresponding resource pool starts remote sessions.\n", ) - idle_threshold: Optional[int] = Field( + idle_threshold: int | None = Field( None, description="A threshold in seconds after which a session gets hibernated (0 means no threshold)", ge=0, le=2147483647, ) - hibernation_threshold: Optional[int] = Field( + hibernation_threshold: int | None = Field( None, description="A threshold in seconds after which a session gets culled/deleted (0 means no threshold)", ge=0, le=2147483647, ) - hibernation_warning_period: Optional[int] = Field( + hibernation_warning_period: int | None = Field( None, description="A duration in seconds, lower than `HibernationThreshold`,\nwhich indicates when to let the user know about a session that\nis going to be hibernated. If `0` is specified, some default\nvalue will be chosen. If no `HibernationThreshold` is defined,\nthis value is ignored.\n", ge=0, le=2147483647, ) - cluster_id: Optional[str] = Field( + cluster_id: str | None = Field( None, description="ULID identifier", max_length=26, @@ -1040,7 +1109,7 @@ class ResourcePoolWithIdFiltered(BaseAPISpec): pattern="^[0-7][0-9A-HJKMNP-TV-Z]{25}$", ) platform: RuntimePlatform - credits_used: Optional[int] = Field( + credits_used: int | None = Field( None, description="Amount of the resource credits used so far", examples=[300], @@ -1048,9 +1117,9 @@ class ResourcePoolWithIdFiltered(BaseAPISpec): ) -class ResourcePoolsWithId(RootModel[List[ResourcePoolWithId]]): - root: List[ResourcePoolWithId] +class ResourcePoolsWithId(RootModel[list[ResourcePoolWithId]]): + root: list[ResourcePoolWithId] -class ResourcePoolsWithIdFiltered(RootModel[List[ResourcePoolWithIdFiltered]]): - root: List[ResourcePoolWithIdFiltered] +class ResourcePoolsWithIdFiltered(RootModel[list[ResourcePoolWithIdFiltered]]): + root: list[ResourcePoolWithIdFiltered] From 778b801da941c0e4a2347d36ed672a829cbb416b Mon Sep 17 00:00:00 2001 From: Salim Kayal Date: Tue, 5 May 2026 17:28:01 +0200 Subject: [PATCH 02/24] feat: add type-safe overloads and resource pool member lookup --- components/renku_data_services/authz/authz.py | 212 +++++++++++++++++- 1 file changed, 207 insertions(+), 5 deletions(-) diff --git a/components/renku_data_services/authz/authz.py b/components/renku_data_services/authz/authz.py index 3fd1d5f34..eea149300 100644 --- a/components/renku_data_services/authz/authz.py +++ b/components/renku_data_services/authz/authz.py @@ -5,7 +5,7 @@ from dataclasses import dataclass, field from enum import StrEnum from functools import wraps -from typing import ClassVar, Concatenate, ParamSpec, Protocol, TypeVar, cast +from typing import Any, ClassVar, Concatenate, Literal, ParamSpec, Protocol, TypeVar, cast, overload from authzed.api.v1 import ( AsyncClient, @@ -241,8 +241,44 @@ def resource_pool(id: int) -> ObjectReference: """The id should be the id of the ResourcePoolORM object in the DB.""" return ObjectReference(object_type=ResourceType.resource_pool.value, object_id=str(id)) + @overload @staticmethod - def to_object(resource_type: ResourceType, resource_id: _ID) -> ObjectReference: + def to_object(resource_type: Literal[ResourceType.project], resource_id: ULID) -> ObjectReference: ... + + @overload + @staticmethod + def to_object(resource_type: Literal[ResourceType.group], resource_id: ULID) -> ObjectReference: ... + + @overload + @staticmethod + def to_object(resource_type: Literal[ResourceType.user_namespace], resource_id: ULID) -> ObjectReference: ... + + @overload + @staticmethod + def to_object(resource_type: Literal[ResourceType.data_connector], resource_id: ULID) -> ObjectReference: ... + + @overload + @staticmethod + def to_object(resource_type: Literal[ResourceType.user], resource_id: str | None) -> ObjectReference: ... + + @overload + @staticmethod + def to_object(resource_type: Literal[ResourceType.anonymous_user], resource_id: Any) -> ObjectReference: ... + + @overload + @staticmethod + def to_object(resource_type: Literal[ResourceType.resource_pool], resource_id: int) -> ObjectReference: ... + + @overload + @staticmethod + def to_object(resource_type: Literal[ResourceType.platform], resource_id: Any) -> ObjectReference: ... + + @overload + @staticmethod + def to_object(resource_type: ResourceType, resource_id: _ID | None) -> ObjectReference: ... + + @staticmethod + def to_object(resource_type: ResourceType, resource_id: _ID | None) -> ObjectReference: """Convert a resource type and ID to an Authzed ObjectReference.""" match (resource_type, resource_id): case (ResourceType.project, sid) if isinstance(sid, ULID): @@ -396,6 +432,67 @@ def client(self) -> AsyncClient: self._client = self.authz_config.authz_async_client() return self._client + @overload + async def _has_permission( + self, user: base_models.APIUser, resource_type: Literal[ResourceType.project], resource_id: ULID, scope: Scope + ) -> tuple[bool, ZedToken | None]: ... + + @overload + async def _has_permission( + self, user: base_models.APIUser, resource_type: Literal[ResourceType.group], resource_id: ULID, scope: Scope + ) -> tuple[bool, ZedToken | None]: ... + + @overload + async def _has_permission( + self, + user: base_models.APIUser, + resource_type: Literal[ResourceType.user_namespace], + resource_id: ULID, + scope: Scope, + ) -> tuple[bool, ZedToken | None]: ... + + @overload + async def _has_permission( + self, + user: base_models.APIUser, + resource_type: Literal[ResourceType.data_connector], + resource_id: ULID, + scope: Scope, + ) -> tuple[bool, ZedToken | None]: ... + + @overload + async def _has_permission( + self, user: base_models.APIUser, resource_type: Literal[ResourceType.user], resource_id: str, scope: Scope + ) -> tuple[bool, ZedToken | None]: ... + + @overload + async def _has_permission( + self, + user: base_models.APIUser, + resource_type: Literal[ResourceType.anonymous_user], + resource_id: Any, + scope: Scope, + ) -> tuple[bool, ZedToken | None]: ... + + @overload + async def _has_permission( + self, + user: base_models.APIUser, + resource_type: Literal[ResourceType.resource_pool], + resource_id: int, + scope: Scope, + ) -> tuple[bool, ZedToken | None]: ... + + @overload + async def _has_permission( + self, user: base_models.APIUser, resource_type: Literal[ResourceType.platform], resource_id: Any, scope: Scope + ) -> tuple[bool, ZedToken | None]: ... + + @overload + async def _has_permission( + self, user: base_models.APIUser, resource_type: ResourceType, resource_id: _ID | None, scope: Scope + ) -> tuple[bool, ZedToken | None]: ... + async def _has_permission( self, user: base_models.APIUser, resource_type: ResourceType, resource_id: _ID | None, scope: Scope ) -> tuple[bool, ZedToken | None]: @@ -419,6 +516,67 @@ async def _has_permission( ) return response.permissionship == CheckPermissionResponse.PERMISSIONSHIP_HAS_PERMISSION, response.checked_at + @overload + async def has_permission( + self, user: base_models.APIUser, resource_type: Literal[ResourceType.project], resource_id: ULID, scope: Scope + ) -> bool: ... + + @overload + async def has_permission( + self, user: base_models.APIUser, resource_type: Literal[ResourceType.group], resource_id: ULID, scope: Scope + ) -> bool: ... + + @overload + async def has_permission( + self, + user: base_models.APIUser, + resource_type: Literal[ResourceType.user_namespace], + resource_id: ULID, + scope: Scope, + ) -> bool: ... + + @overload + async def has_permission( + self, + user: base_models.APIUser, + resource_type: Literal[ResourceType.data_connector], + resource_id: ULID, + scope: Scope, + ) -> bool: ... + + @overload + async def has_permission( + self, user: base_models.APIUser, resource_type: Literal[ResourceType.user], resource_id: str, scope: Scope + ) -> bool: ... + + @overload + async def has_permission( + self, + user: base_models.APIUser, + resource_type: Literal[ResourceType.anonymous_user], + resource_id: Any, + scope: Scope, + ) -> bool: ... + + @overload + async def has_permission( + self, + user: base_models.APIUser, + resource_type: Literal[ResourceType.resource_pool], + resource_id: int, + scope: Scope, + ) -> bool: ... + + @overload + async def has_permission( + self, user: base_models.APIUser, resource_type: Literal[ResourceType.platform], resource_id: Any, scope: Scope + ) -> bool: ... + + @overload + async def has_permission( + self, user: base_models.APIUser, resource_type: ResourceType, resource_id: _ID, scope: Scope + ) -> bool: ... + async def has_permission( self, user: base_models.APIUser, resource_type: ResourceType, resource_id: _ID, scope: Scope ) -> bool: @@ -524,7 +682,7 @@ async def users_with_permission( self, user: base_models.APIUser, resource_type: ResourceType, - resource_id: str, + resource_id: _ID, scope: Scope, # The scope that the users should be allowed to exercise on the resource *, zed_token: ZedToken | None = None, @@ -546,6 +704,46 @@ async def users_with_permission( ids.append(response.subject.subject_object_id) return ids + async def get_resource_pool_members( + self, + user: base_models.APIUser, + resource_pool_id: int, + *, + zed_token: ZedToken | None = None, + ) -> list[tuple[str, str, str]]: + """Get all members of a resource pool from Authzed. + + Returns a list of tuples: (subject_type, subject_id, relation). + Skips public_viewer and resource_pool_platform relations. + """ + if isinstance(user, InternalServiceAdmin): + pass + elif not user.is_admin: + return [] + + consistency = Consistency(at_least_as_fresh=zed_token) if zed_token else Consistency(fully_consistent=True) + rel_filter = RelationshipFilter( + resource_type=ResourceType.resource_pool.value, + optional_resource_id=str(resource_pool_id), + ) + responses: AsyncIterable[ReadRelationshipsResponse] = self.client.ReadRelationships( + ReadRelationshipsRequest( + consistency=consistency, + relationship_filter=rel_filter, + ) + ) + + members: list[tuple[str, str, str]] = [] + skip_relations = {_Relation.public_viewer.value, _Relation.resource_pool_platform.value} + async for response in responses: + rel = response.relationship + if rel.relation in skip_relations: + continue + subject_type = rel.subject.object.object_type + subject_id = rel.subject.object.object_id + members.append((subject_type, subject_id, rel.relation)) + return members + async def get_all_members( self, resource_type: ResourceType, *, zed_token: ZedToken | None = None ) -> AsyncGenerator[Member, None]: @@ -1476,10 +1674,14 @@ def _resource_pool_membership_changes_to_authz_change( resource = _AuthzConverter.resource_pool(cast(int, member.resource_id)) match member.subject_type: - case ResourceType.group if member.role == Role.VIEWER: + case ResourceType.group: + if member.role == Role.PROHIBITED: + raise errors.ValidationError(message="Groups cannot be prohibited from resource pools") relation = _Relation.group_viewer.value subject = SubjectReference(object=_AuthzConverter.group(ULID.from_str(member.user_id))) - case ResourceType.project if member.role == Role.VIEWER: + case ResourceType.project: + if member.role == Role.PROHIBITED: + raise errors.ValidationError(message="Projects cannot be prohibited from resource pools") relation = _Relation.project_viewer.value subject = SubjectReference(object=_AuthzConverter.project(ULID.from_str(member.user_id))) case _: From e0bfc420e2049ebcaae1162a335a2cf3a8ed3ee9 Mon Sep 17 00:00:00 2001 From: Salim Kayal Date: Tue, 5 May 2026 17:28:45 +0200 Subject: [PATCH 03/24] feat: refactor MemberRepository to support polymorphic members via authz --- components/renku_data_services/crc/db.py | 215 +++++++++++++++++++---- 1 file changed, 181 insertions(+), 34 deletions(-) diff --git a/components/renku_data_services/crc/db.py b/components/renku_data_services/crc/db.py index 9627f80a3..1a36dca0f 100644 --- a/components/renku_data_services/crc/db.py +++ b/components/renku_data_services/crc/db.py @@ -918,6 +918,15 @@ class Repository2Users: disallowed: list[base_models.User] = field(default_factory=list) +@dataclass +class ResourcePoolMemberResult: + """A single member of a resource pool.""" + + member_type: MemberType + member_id: str + relation: str + + class MemberRepository(_Base): """The adapter used for accessing resource pool users with SQLAlchemy.""" @@ -943,47 +952,64 @@ async def get_resource_pool_users( resource_pool_id: int, keycloak_id: Optional[str] = None, ) -> Repository2Users: - """Get users of a specific resource pool from the database.""" - async with self.session_maker() as session, session.begin(): - stmt = ( - select(schemas.ResourcePoolORM) - .where(schemas.ResourcePoolORM.id == resource_pool_id) - .options(selectinload(schemas.ResourcePoolORM.users)) + """Get users of a specific resource pool using Authzed as the source of truth.""" + async with self.session_maker() as session: + rp = await session.scalar( + select(schemas.ResourcePoolORM).where(schemas.ResourcePoolORM.id == resource_pool_id) ) - if keycloak_id is not None: - stmt = stmt.join(schemas.ResourcePoolORM.users, isouter=True).where( - or_( - schemas.UserORM.keycloak_id == keycloak_id, - schemas.ResourcePoolORM.public == true(), - schemas.ResourceClassORM.default == true(), - ) - ) - res = await session.execute(stmt) - rp = res.scalars().first() if rp is None: raise errors.MissingResourceError(message=f"Resource pool with id {resource_pool_id} does not exist") + specific_user: base_models.User | None = None - if keycloak_id: + if keycloak_id is not None: specific_user_res = ( await session.execute(select(schemas.UserORM).where(schemas.UserORM.keycloak_id == keycloak_id)) ).scalar_one_or_none() specific_user = None if not specific_user_res else specific_user_res.dump() - allowed: list[base_models.User] = [] - disallowed: list[base_models.User] = [] + if rp.default: + # Default pools use the no_default_access flag; preserve this behaviour + # so that update_resource_pool_users can find disallowed users. disallowed_stmt = select(schemas.UserORM).where(schemas.UserORM.no_default_access == true()) if keycloak_id: disallowed_stmt = disallowed_stmt.where(schemas.UserORM.keycloak_id == keycloak_id) disallowed_res = await session.execute(disallowed_stmt) disallowed = [user.dump() for user in disallowed_res.scalars().all()] + allowed: list[base_models.User] = [] if specific_user and specific_user not in disallowed: allowed = [specific_user] - elif rp.public and not rp.default: + return Repository2Users(rp.id, allowed, disallowed) + + if rp.public and not rp.default: + allowed = [] if specific_user: allowed = [specific_user] - elif not rp.public and not rp.default: - allowed = [user.dump() for user in rp.users] - return Repository2Users(rp.id, allowed, disallowed) + return Repository2Users(rp.id, allowed, []) + + # Non-default, non-public pools: resolve from Authzed. + user_ids = await self.authz.users_with_permission( + api_user, ResourceType.resource_pool, resource_pool_id, Scope.READ + ) + + if keycloak_id: + if keycloak_id not in user_ids: + return Repository2Users(resource_pool_id, allowed=[], disallowed=[]) + user = await self.kc_user_repo.get_user(id=keycloak_id) + if user is None: + raise errors.MissingResourceError(message=f"The user with id {keycloak_id} cannot be found.") + return Repository2Users( + resource_pool_id, + allowed=[base_models.User(keycloak_id=keycloak_id, no_default_access=False)], + disallowed=[], + ) + + allowed = [] + for uid in user_ids: + user = await self.kc_user_repo.get_user(id=uid) + if user is not None: + allowed.append(base_models.User(keycloak_id=user.id, no_default_access=False)) + + return Repository2Users(resource_pool_id, allowed=allowed, disallowed=[]) async def get_user_resource_pools( self, @@ -1167,17 +1193,45 @@ async def update_resource_pool_users( ] await self._grant_resource_pool_members(api_user, resource_pool_id, member_ids, session=session) else: + current_user_ids = {u.keycloak_id for u in rp.users} + new_user_ids = set(user_ids) + to_remove_ids = current_user_ids - new_user_ids + to_add_ids = new_user_ids - current_user_ids rp.users = list(users_to_add_exist) + users_to_add_missing + if to_remove_ids: + to_remove = [ + ResourcePoolMemberIdentifier(member_id=uid, member_type=MemberType.USER) + for uid in to_remove_ids + ] + await self._revoke_resource_pool_members(api_user, resource_pool_id, to_remove, session=session) + if to_add_ids: + to_add = [ + ResourcePoolMemberIdentifier(member_id=uid, member_type=MemberType.USER) for uid in to_add_ids + ] + await self._grant_resource_pool_members(api_user, resource_pool_id, to_add, session=session) return [usr.dump() for usr in rp.users] + @staticmethod + def _relation_to_role(relation: str, member_type: MemberType) -> Role: + """Map an API relation string to an authorization Role.""" + match member_type: + case MemberType.USER: + if relation == "prohibited": + return Role.PROHIBITED + return Role.VIEWER + case MemberType.GROUP | MemberType.PROJECT: + if relation == "prohibited": + raise errors.ValidationError( + message=f"Member type {member_type.value} cannot have a prohibited relation" + ) + return Role.VIEWER + def _build_pool_membership_changes( self, resource_pool_id: int, specs: Collection[tuple[str, ResourceType, Role]], change: Change, - ) -> models.ResourcePoolMembershipChange | None: - if not specs: - return None + ) -> models.ResourcePoolMembershipChange: return models.ResourcePoolMembershipChange( changes=[ MembershipChange( @@ -1198,30 +1252,30 @@ async def _resolve_members( self, api_user: base_models.APIUser, members: Collection[ResourcePoolMemberIdentifier], - ) -> list[tuple[str, ResourceType]]: - """Resolve ResourcePoolMemberIdentifiers to (internal_id, subject_type) tuples.""" - resolved: list[tuple[str, ResourceType]] = [] + ) -> list[tuple[str, ResourceType, str]]: + """Resolve ResourcePoolMemberIdentifiers to (internal_id, subject_type, relation) tuples.""" + resolved: list[tuple[str, ResourceType, str]] = [] for member in members: match member.member_type: case MemberType.USER: kc_user = await self.kc_user_repo.get_user(id=member.member_id) if kc_user is None: raise errors.MissingResourceError(message=f"User with ID {member.member_id} does not exist") - resolved.append((kc_user.id, ResourceType.user)) + resolved.append((kc_user.id, ResourceType.user, member.relation)) case MemberType.GROUP: group_id = ULID.from_str(member.member_id) group = await self.group_repo.get_group_by_id(api_user, group_id) if group is None: raise errors.MissingResourceError(message=f"Group with id {member.member_id!r} does not exist") - resolved.append((str(group.id), ResourceType.group)) + resolved.append((str(group.id), ResourceType.group, member.relation)) case MemberType.PROJECT: project_id = ULID.from_str(member.member_id) project = await self.project_repo.get_project_by_id(api_user, project_id) if project is None: raise errors.MissingResourceError(message=f"Project {member.member_id!r} does not exist") - resolved.append((str(project.id), ResourceType.project)) + resolved.append((str(project.id), ResourceType.project, member.relation)) return resolved @@ -1261,7 +1315,10 @@ async def _grant_resource_pool_members( specs = await self._resolve_members(api_user, members) return self._build_pool_membership_changes( resource_pool_id, - [(member_id, subject_type, Role.VIEWER) for member_id, subject_type in specs], + [ + (member_id, subject_type, self._relation_to_role(relation, member.member_type)) + for (member_id, subject_type, relation), member in zip(specs, members, strict=True) + ], Change.ADD, ) @@ -1277,7 +1334,10 @@ async def _revoke_resource_pool_members( specs = await self._resolve_members(api_user, members) return self._build_pool_membership_changes( resource_pool_id, - [(member_id, subject_type, Role.VIEWER) for member_id, subject_type in specs], + [ + (member_id, subject_type, self._relation_to_role(relation, member.member_type)) + for (member_id, subject_type, relation), member in zip(specs, members, strict=True) + ], Change.REMOVE, ) @@ -1304,6 +1364,93 @@ async def revoke_resource_pool_members( async with self.session_maker() as session, session.begin(): await self._revoke_resource_pool_members(api_user, resource_pool_id, members, session=session) + @_only_admins + async def get_resource_pool_members( + self, + api_user: base_models.APIUser, + resource_pool_id: int, + ) -> list[ResourcePoolMemberResult]: + """Get all members of a resource pool from Authzed, resolving IDs to human-readable identifiers.""" + raw_members = await self.authz.get_resource_pool_members(api_user, resource_pool_id) + results: list[ResourcePoolMemberResult] = [] + for subject_type, subject_id, relation in raw_members: + match subject_type: + case ResourceType.user.value: + results.append( + ResourcePoolMemberResult(member_type=MemberType.USER, member_id=subject_id, relation=relation) + ) + case ResourceType.group.value: + try: + await self.group_repo.get_group_by_id(api_user, ULID.from_str(subject_id)) + results.append( + ResourcePoolMemberResult( + member_type=MemberType.GROUP, + member_id=subject_id, + relation=relation, + ) + ) + except (errors.MissingResourceError, ValueError): + logger.warning( + f"Skipping orphaned group relation for resource pool {resource_pool_id}: group {subject_id}" + ) + case ResourceType.project.value: + try: + await self.project_repo.get_project_by_id(api_user, ULID.from_str(subject_id)) + results.append( + ResourcePoolMemberResult( + member_type=MemberType.PROJECT, + member_id=subject_id, + relation=relation, + ) + ) + except (errors.MissingResourceError, ValueError): + logger.warning( + f"Skipping orphaned project relation for resource pool {resource_pool_id}: " + f"project {subject_id}" + ) + return results + + @_only_admins + async def update_resource_pool_members( + self, + api_user: base_models.APIUser, + resource_pool_id: int, + members: Collection[ResourcePoolMemberIdentifier], + append: bool = True, + ) -> list[ResourcePoolMemberResult]: + """Update the members of a resource pool. + + POST (append=True) adds the given members. PUT (append=False) replaces all + members with the given set. + """ + async with self.session_maker() as session, session.begin(): + rp = await session.scalar( + select(schemas.ResourcePoolORM).where(schemas.ResourcePoolORM.id == resource_pool_id) + ) + if rp is None: + raise errors.MissingResourceError(message=f"Resource pool with id {resource_pool_id} does not exist") + + if not append: + current_members = await self.get_resource_pool_members(api_user, resource_pool_id) + current_set = {(m.member_type, m.member_id, m.relation) for m in current_members} + desired_set = {(m.member_type, m.member_id, m.relation) for m in members} + + to_remove = [ + ResourcePoolMemberIdentifier(member_id=m.member_id, member_type=m.member_type, relation=m.relation) + for m in current_members + if (m.member_type, m.member_id, m.relation) not in desired_set + ] + to_add = [m for m in members if (m.member_type, m.member_id, m.relation) not in current_set] + + if to_remove: + await self._revoke_resource_pool_members(api_user, resource_pool_id, to_remove, session=session) + if to_add: + await self._grant_resource_pool_members(api_user, resource_pool_id, to_add, session=session) + else: + await self._grant_resource_pool_members(api_user, resource_pool_id, members, session=session) + + return await self.get_resource_pool_members(api_user, resource_pool_id) + @_only_admins async def update_user(self, api_user: base_models.APIUser, keycloak_id: str, **kwargs: Any) -> base_models.User: """Update a specific user.""" From f7469b0063adba0dab7aed9c691e3e322494afac Mon Sep 17 00:00:00 2001 From: Salim Kayal Date: Tue, 5 May 2026 17:29:18 +0200 Subject: [PATCH 04/24] feat: add ResourcePoolMembersBP blueprint and register routes --- bases/renku_data_services/data_api/app.py | 8 + .../renku_data_services/crc/blueprints.py | 152 ++++++++++++++++++ 2 files changed, 160 insertions(+) diff --git a/bases/renku_data_services/data_api/app.py b/bases/renku_data_services/data_api/app.py index 3476a2e9d..a234e03a6 100644 --- a/bases/renku_data_services/data_api/app.py +++ b/bases/renku_data_services/data_api/app.py @@ -20,6 +20,7 @@ ClassesBP, ClustersBP, QuotaBP, + ResourcePoolMembersBP, ResourcePoolsBP, ResourcePoolUsersBP, UserResourcePoolsBP, @@ -104,6 +105,12 @@ def register_all_handlers(app: Sanic, dm: DependencyManager) -> Sanic: authenticator=dm.authenticator, kc_user_repo=dm.kc_user_repo, ) + resource_pools_members = ResourcePoolMembersBP( + name="resource_pool_members", + url_prefix=url_prefix, + repo=dm.member_repo, + authenticator=dm.authenticator, + ) user_resource_pools = UserResourcePoolsBP( name="user_resource_pools", url_prefix=url_prefix, @@ -309,6 +316,7 @@ def register_all_handlers(app: Sanic, dm: DependencyManager) -> Sanic: classes.blueprint(), quota.blueprint(), resource_pools_users.blueprint(), + resource_pools_members.blueprint(), users.blueprint(), user_secrets.blueprint(), user_resource_pools.blueprint(), diff --git a/components/renku_data_services/crc/blueprints.py b/components/renku_data_services/crc/blueprints.py index a14f3e2e1..3bb229a5b 100644 --- a/components/renku_data_services/crc/blueprints.py +++ b/components/renku_data_services/crc/blueprints.py @@ -24,6 +24,7 @@ validate_resource_pool_put_or_patch, ) from renku_data_services.crc.db import ClusterRepository, MemberRepository, ResourcePoolRepository +from renku_data_services.crc.models import MemberType, ResourcePoolMemberIdentifier from renku_data_services.users.db import UserRepo as KcUserRepo from renku_data_services.users.models import UserInfo @@ -245,6 +246,157 @@ async def _delete( return "/resource_pools//users/", ["DELETE"], _delete +@dataclass(kw_only=True) +class ResourcePoolMembersBP(CustomBlueprint): + """Handlers for dealing with polymorphic members of individual resource pools.""" + + repo: MemberRepository + authenticator: base_models.Authenticator + + def get_all(self) -> BlueprintFactoryResponse: + """Get all members of a specific resource pool.""" + + @authenticate(self.authenticator) + @only_admins + @validate_db_ids + async def _get_all(_: Request, user: base_models.APIUser, resource_pool_id: int) -> HTTPResponse: + members = await self.repo.get_resource_pool_members(user, resource_pool_id) + return validated_json( + apispec.PoolMembers, + [{"member_type": m.member_type.value, "id": m.member_id, "relation": m.relation} for m in members], + ) + + return "/resource_pools//members", ["GET"], _get_all + + def post(self) -> BlueprintFactoryResponse: + """Add members to a specific resource pool.""" + + @authenticate(self.authenticator) + @only_admins + @validate_db_ids + @validate(json=apispec.PoolMembers) + async def _post( + _: Request, user: base_models.APIUser, resource_pool_id: int, body: apispec.PoolMembers + ) -> HTTPResponse: + return await self._put_post(user, resource_pool_id, body, post=True) + + return "/resource_pools//members", ["POST"], _post + + def put(self) -> BlueprintFactoryResponse: + """Set the members of a specific resource pool.""" + + @authenticate(self.authenticator) + @only_admins + @validate_db_ids + @validate(json=apispec.PoolMembers) + async def _put( + _: Request, user: base_models.APIUser, resource_pool_id: int, body: apispec.PoolMembers + ) -> HTTPResponse: + return await self._put_post(user, resource_pool_id, body, post=False) + + return "/resource_pools//members", ["PUT"], _put + + async def _put_post( + self, user: base_models.APIUser, resource_pool_id: int, body: apispec.PoolMembers, post: bool = True + ) -> HTTPResponse: + identifiers = self._to_identifiers(body) + updated = await self.repo.update_resource_pool_members( + api_user=user, + resource_pool_id=resource_pool_id, + members=identifiers, + append=post, + ) + return validated_json( + apispec.PoolMembers, + [{"member_type": m.member_type.value, "id": m.member_id, "relation": m.relation} for m in updated], + status=201 if post else 200, + ) + + def get(self) -> BlueprintFactoryResponse: + """Check if a specific member belongs to a resource pool.""" + + @authenticate(self.authenticator) + @only_admins + @validate_db_ids + async def _get( + _: Request, user: base_models.APIUser, resource_pool_id: int, member_type: str, member_id: str + ) -> HTTPResponse: + members = await self.repo.get_resource_pool_members(user, resource_pool_id) + for m in members: + if m.member_type.value == member_type and m.member_id == member_id: + payload = {"member_type": m.member_type.value, "id": m.member_id, "relation": m.relation} + match member_type: + case "user": + return validated_json(apispec.PoolMemberUser, payload) + case "group": + return validated_json(apispec.PoolMemberGroup, payload) + case "project": + return validated_json(apispec.PoolMemberProject, payload) + case _: + raise errors.ValidationError(message=f"Invalid member type: {member_type}") + raise errors.MissingResourceError( + message=f"The member with type {member_type} and id {member_id} does not belong to the resource pool." + ) + + return "/resource_pools//members//", ["GET"], _get + + def delete(self) -> BlueprintFactoryResponse: + """Remove a specific member from a resource pool.""" + + @authenticate(self.authenticator) + @only_admins + @validate_db_ids + async def _delete( + _: Request, user: base_models.APIUser, resource_pool_id: int, member_type: str, member_id: str + ) -> HTTPResponse: + identifier = ResourcePoolMemberIdentifier( + member_id=member_id, + member_type=MemberType(member_type), + ) + try: + await self.repo.revoke_resource_pool_members( + api_user=user, + resource_pool_id=resource_pool_id, + members=[identifier], + ) + except errors.MissingResourceError: + pass # Already removed or was not part of the pool + return HTTPResponse(status=204) + + return "/resource_pools//members//", ["DELETE"], _delete + + @staticmethod + def _to_identifiers(body: apispec.PoolMembers) -> list[ResourcePoolMemberIdentifier]: + identifiers: list[ResourcePoolMemberIdentifier] = [] + for item in body.root: + match item: + case apispec.PoolMemberUser(): + identifiers.append( + ResourcePoolMemberIdentifier( + member_id=item.id, + member_type=MemberType.USER, + relation=item.relation.value, + ) + ) + case apispec.PoolMemberGroup(): + identifiers.append( + ResourcePoolMemberIdentifier( + member_id=item.id, + member_type=MemberType.GROUP, + relation=item.relation.value, + ) + ) + case apispec.PoolMemberProject(): + identifiers.append( + ResourcePoolMemberIdentifier( + member_id=item.id, + member_type=MemberType.PROJECT, + relation=item.relation.value, + ) + ) + return identifiers + + @dataclass(kw_only=True) class ClassesBP(CustomBlueprint): """Handlers for dealing with resource classes of an individual resource pool.""" From 2fb0d6665e3d0229a5bf55857d8a26d192bbf6b9 Mon Sep 17 00:00:00 2001 From: Salim Kayal Date: Tue, 5 May 2026 17:30:40 +0200 Subject: [PATCH 05/24] fix: add defaults to member identifier --- components/renku_data_services/crc/models.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/components/renku_data_services/crc/models.py b/components/renku_data_services/crc/models.py index 1841b892f..ff48758bb 100644 --- a/components/renku_data_services/crc/models.py +++ b/components/renku_data_services/crc/models.py @@ -514,6 +514,7 @@ class ResourcePoolMemberIdentifier: member_id: str member_type: MemberType + relation: str = "" def __post_init__(self) -> None: if not self.member_id or not self.member_id.strip(): @@ -528,6 +529,17 @@ def __post_init__(self) -> None: ) from e case MemberType.USER: pass + # Apply defaults if relation is empty + object.__setattr__( + self, + "relation", + self.relation + or { + MemberType.USER: "viewer", + MemberType.GROUP: "group_viewer", + MemberType.PROJECT: "project_viewer", + }.get(self.member_type, "viewer"), + ) @classmethod def from_resource(cls, resource_type: ResourceType, resource_id: ULID) -> ResourcePoolMemberIdentifier: From 370e865dcc95ec96951b4bf508edb47cf24095d3 Mon Sep 17 00:00:00 2001 From: Salim Kayal Date: Tue, 5 May 2026 17:32:43 +0200 Subject: [PATCH 06/24] fix: update resource pool user test expectations for authzed resolution --- .../data_api/test_resource_pools.py | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/test/bases/renku_data_services/data_api/test_resource_pools.py b/test/bases/renku_data_services/data_api/test_resource_pools.py index 01fc853b5..5c99774a7 100644 --- a/test/bases/renku_data_services/data_api/test_resource_pools.py +++ b/test/bases/renku_data_services/data_api/test_resource_pools.py @@ -6,6 +6,7 @@ import pytest from sanic_testing.testing import SanicASGITestClient +from ulid import ULID from renku_data_services.resource_usage.db import ResourceRequestsRepo from renku_data_services.resource_usage.model import Credit, ResourceClassCost, ResourcePoolLimits @@ -724,6 +725,8 @@ async def test_remove_resource_pool_users( assert res.status_code == 200 assert len(existing_users) >= 3 # Give another user access to the private pool + admin = existing_users[0] + admin_id = admin["id"] allowed_user = existing_users[1] allowed_user2 = existing_users[2] allowed_user_id = allowed_user["id"] @@ -748,8 +751,9 @@ async def test_remove_resource_pool_users( headers=admin_headers, ) assert res.status_code == 200 - assert len(res.json) == 2 - assert set([u["id"] for u in res.json]) == {allowed_user_id, allowed_user2_id} + # Authzed resolves ALL users with read permission (including inherited access i.e. in this case "admin"), + assert len(res.json) == 3 + assert set([u["id"] for u in res.json]) == {admin_id, allowed_user_id, allowed_user2_id} # Remove the user from the private pool _, res = await sanic_client.delete( f"/api/data/resource_pools/{rp_private['id']}/users/{allowed_user_id}", @@ -767,7 +771,8 @@ async def test_remove_resource_pool_users( headers=admin_headers, ) assert res.status_code == 200 - assert len(res.json) == 1 + # Authzed resolves ALL users with read permission (including inherited access i.e. in this case "admin"), + assert len(res.json) == 2 assert len([user for user in res.json if user.get("id") == allowed_user_id]) == 0 # The remaining user can see the pool user2_access_token = json.dumps({"id": allowed_user2_id}) From 3903e6b203a1ecf3a6588823d126a609fbbf1941 Mon Sep 17 00:00:00 2001 From: Salim Kayal Date: Tue, 5 May 2026 17:33:17 +0200 Subject: [PATCH 07/24] test: add integration tests for resource pool members endpoints --- .../data_api/test_resource_pools.py | 837 ++++++++++++++++++ 1 file changed, 837 insertions(+) diff --git a/test/bases/renku_data_services/data_api/test_resource_pools.py b/test/bases/renku_data_services/data_api/test_resource_pools.py index 5c99774a7..72fc5dbcd 100644 --- a/test/bases/renku_data_services/data_api/test_resource_pools.py +++ b/test/bases/renku_data_services/data_api/test_resource_pools.py @@ -1707,6 +1707,7 @@ async def test_resource_pool_visibility_toggle( @pytest.mark.asyncio @pytest.mark.xdist_group("sessions") + @pytest.mark.parametrize("total_limit,user_limit", [(1000, 200), (200, 1000), (200, 0), (0, 200)]) async def test_resource_pools_quota_with_partial_usage( sanic_client, @@ -1859,9 +1860,66 @@ async def test_resource_pools_quota_with_no_usage( # usage_hours_total should be 4 hours (200 credits total limit / 50 credits per hour) assert resource_class["usage_hours_total"] == 4.0 +async def test_resource_pool_members_add_group( + sanic_client: SanicASGITestClient, + admin_headers: dict[str, str], + valid_resource_pool_payload: dict[str, Any], + create_group, + member_1_headers, + member_1_user, + cluster: KindCluster, +) -> None: + valid_resource_pool_payload["default"] = False + valid_resource_pool_payload["public"] = False + _, res = await create_rp(valid_resource_pool_payload, sanic_client) + assert res.status_code == 201 + rp = res.json + + group = await create_group( + sanic_client, "test-pool-group", admin=True, members=[{"id": member_1_user.id, "role": "viewer"}] + ) + + # Add the group to the pool via /members + member_payload = [{"member_type": "group", "id": group["id"], "relation": "group_viewer"}] + _, res = await sanic_client.post( + f"/api/data/resource_pools/{rp['id']}/members", + headers=admin_headers, + json=member_payload, + ) + assert res.status_code == 201 + + # GET /members should return the group + _, res = await sanic_client.get( + f"/api/data/resource_pools/{rp['id']}/members", + headers=admin_headers, + ) + assert res.status_code == 200 + members = res.json + group_members = [m for m in members if m.get("member_type") == "group" and m.get("id") == group["id"]] + assert len(group_members) == 1 + + # member_1 (in the group) should now be able to access the pool + _, res = await sanic_client.get( + f"/api/data/resource_pools/{rp['id']}", + headers=member_1_headers, + ) + assert res.status_code == 200 + + # GET /users should resolve and include member_1 + _, res = await sanic_client.get( + f"/api/data/resource_pools/{rp['id']}/users", + headers=admin_headers, + ) + assert res.status_code == 200 + users = res.json + user_ids = [u["id"] for u in users] + assert member_1_user.id in user_ids + + @pytest.mark.asyncio @pytest.mark.xdist_group("sessions") + async def test_resource_pools_quota_exceeded( sanic_client: SanicASGITestClient, admin_headers: dict[str, str], @@ -1942,9 +2000,66 @@ async def test_resource_pools_quota_exceeded( # usage_hours_total should be 2.0 hours (50 credits/hour * 2 hours = 100 credits limit) assert resource_class["usage_hours_total"] == 2.0 +async def test_resource_pool_members_add_project( + sanic_client: SanicASGITestClient, + admin_headers: dict[str, str], + valid_resource_pool_payload: dict[str, Any], + create_project, + member_1_headers, + member_1_user, + cluster: KindCluster, +) -> None: + valid_resource_pool_payload["default"] = False + valid_resource_pool_payload["public"] = False + _, res = await create_rp(valid_resource_pool_payload, sanic_client) + assert res.status_code == 201 + rp = res.json + + project = await create_project( + sanic_client, "test-pool-project", admin=True, members=[{"id": member_1_user.id, "role": "viewer"}] + ) + + # Add the project to the pool via /members + member_payload = [{"member_type": "project", "id": project["id"], "relation": "project_viewer"}] + _, res = await sanic_client.post( + f"/api/data/resource_pools/{rp['id']}/members", + headers=admin_headers, + json=member_payload, + ) + assert res.status_code == 201 + + # GET /members should return the project + _, res = await sanic_client.get( + f"/api/data/resource_pools/{rp['id']}/members", + headers=admin_headers, + ) + assert res.status_code == 200 + members = res.json + project_members = [m for m in members if m.get("member_type") == "project" and m.get("id") == project["id"]] + assert len(project_members) == 1 + + # member_1 (in the project) should now be able to access the pool + _, res = await sanic_client.get( + f"/api/data/resource_pools/{rp['id']}", + headers=member_1_headers, + ) + assert res.status_code == 200 + + # GET /users should resolve and include member_1 + _, res = await sanic_client.get( + f"/api/data/resource_pools/{rp['id']}/users", + headers=admin_headers, + ) + assert res.status_code == 200 + users = res.json + user_ids = [u["id"] for u in users] + assert member_1_user.id in user_ids + + @pytest.mark.asyncio @pytest.mark.xdist_group("sessions") + async def test_resource_pools_quota_with_no_limits( sanic_client: SanicASGITestClient, admin_headers: dict[str, str], @@ -2017,9 +2132,52 @@ async def test_resource_pools_quota_with_no_limits( # usage_hours_total should not exist in the response since it's None assert "usage_hours_total" not in resource_class +async def test_resource_pool_members_put_replaces( + sanic_client: SanicASGITestClient, + admin_headers: dict[str, str], + valid_resource_pool_payload: dict[str, Any], + create_group, + cluster: KindCluster, +) -> None: + valid_resource_pool_payload["default"] = False + valid_resource_pool_payload["public"] = False + _, res = await create_rp(valid_resource_pool_payload, sanic_client) + assert res.status_code == 201 + rp = res.json + + group1 = await create_group(sanic_client, "test-group-1", admin=True) + group2 = await create_group(sanic_client, "test-group-2", admin=True) + + # Add group1 + _, res = await sanic_client.post( + f"/api/data/resource_pools/{rp['id']}/members", + headers=admin_headers, + json=[{"member_type": "group", "id": group1["id"], "relation": "group_viewer"}], + ) + assert res.status_code == 201 + + # PUT with only group2 should replace + _, res = await sanic_client.put( + f"/api/data/resource_pools/{rp['id']}/members", + headers=admin_headers, + json=[{"member_type": "group", "id": group2["id"], "relation": "group_viewer"}], + ) + assert res.status_code == 200 + + _, res = await sanic_client.get( + f"/api/data/resource_pools/{rp['id']}/members", + headers=admin_headers, + ) + assert res.status_code == 200 + members = res.json + assert len(members) == 1 + assert members[0]["id"] == group2["id"] + + @pytest.mark.asyncio @pytest.mark.xdist_group("sessions") + async def test_resource_pools_quota_with_no_costs( sanic_client: SanicASGITestClient, admin_headers: dict[str, str], @@ -2095,3 +2253,682 @@ async def test_resource_pools_quota_with_no_costs( assert "usage_hours_remaining" not in resource_class # usage_hours_total should not exist in the response since it's None assert "usage_hours_total" not in resource_class + +async def test_resource_pool_members_delete( + sanic_client: SanicASGITestClient, + admin_headers: dict[str, str], + valid_resource_pool_payload: dict[str, Any], + create_group, + member_1_headers, + member_1_user, + cluster: KindCluster, +) -> None: + valid_resource_pool_payload["default"] = False + valid_resource_pool_payload["public"] = False + _, res = await create_rp(valid_resource_pool_payload, sanic_client) + assert res.status_code == 201 + rp = res.json + + group = await create_group( + sanic_client, + "test-del-group", + admin=True, + members=[{"id": member_1_user.id, "role": "viewer"}], + ) + + _, res = await sanic_client.post( + f"/api/data/resource_pools/{rp['id']}/members", + headers=admin_headers, + json=[{"member_type": "group", "id": group["id"], "relation": "group_viewer"}], + ) + assert res.status_code == 201 + + # member_1 can access the pool + _, res = await sanic_client.get( + f"/api/data/resource_pools/{rp['id']}", + headers=member_1_headers, + ) + assert res.status_code == 200 + + # Delete the group from the pool + _, res = await sanic_client.delete( + f"/api/data/resource_pools/{rp['id']}/members/group/{group['id']}", + headers=admin_headers, + ) + assert res.status_code == 204 + + # member_1 can no longer access the pool + _, res = await sanic_client.get( + f"/api/data/resource_pools/{rp['id']}", + headers=member_1_headers, + ) + assert res.status_code == 404 + + +@pytest.mark.asyncio +@pytest.mark.xdist_group("sessions") +async def test_resource_pool_members_get_includes_relation( + sanic_client: SanicASGITestClient, + admin_headers: dict[str, str], + valid_resource_pool_payload: dict[str, Any], + cluster: KindCluster, +) -> None: + valid_resource_pool_payload["default"] = False + valid_resource_pool_payload["public"] = False + _, res = await create_rp(valid_resource_pool_payload, sanic_client) + assert res.status_code == 201 + rp = res.json + + _, res = await sanic_client.get("/api/data/users", headers=admin_headers) + existing_users = res.json + user = existing_users[1] + + _, res = await sanic_client.post( + f"/api/data/resource_pools/{rp['id']}/members", + headers=admin_headers, + json=[{"member_type": "user", "id": user["id"], "relation": "viewer"}], + ) + assert res.status_code == 201 + + _, res = await sanic_client.get( + f"/api/data/resource_pools/{rp['id']}/members", + headers=admin_headers, + ) + assert res.status_code == 200 + members = res.json + assert len(members) == 1 + assert members[0]["member_type"] == "user" + assert members[0]["id"] == user["id"] + assert members[0]["relation"] == "viewer" + + +@pytest.mark.asyncio +@pytest.mark.xdist_group("sessions") +async def test_resource_pool_users_resolved_via_authz_empty_for_public( + sanic_client: SanicASGITestClient, + admin_headers: dict[str, str], + valid_resource_pool_payload: dict[str, Any], + cluster: KindCluster, +) -> None: + valid_resource_pool_payload["default"] = False + valid_resource_pool_payload["public"] = True + _, res = await create_rp(valid_resource_pool_payload, sanic_client) + assert res.status_code == 201 + rp = res.json + + _, res = await sanic_client.get( + f"/api/data/resource_pools/{rp['id']}/users", + headers=admin_headers, + ) + assert res.status_code == 200 + assert res.json == [] + + +@pytest.mark.asyncio +@pytest.mark.xdist_group("sessions") +async def test_resource_pool_members_empty_array( + sanic_client: SanicASGITestClient, + admin_headers: dict[str, str], + valid_resource_pool_payload: dict[str, Any], + cluster: KindCluster, +) -> None: + valid_resource_pool_payload["default"] = False + valid_resource_pool_payload["public"] = False + _, res = await create_rp(valid_resource_pool_payload, sanic_client) + assert res.status_code == 201 + rp = res.json + + # POST empty array should succeed and return 201 + _, res = await sanic_client.post( + f"/api/data/resource_pools/{rp['id']}/members", + headers=admin_headers, + json=[], + ) + assert res.status_code == 201 + assert res.json == [] + + # PUT empty array should succeed and return 200 + _, res = await sanic_client.put( + f"/api/data/resource_pools/{rp['id']}/members", + headers=admin_headers, + json=[], + ) + assert res.status_code == 200 + assert res.json == [] + + # PUT empty array on /users should also succeed + _, res = await sanic_client.put( + f"/api/data/resource_pools/{rp['id']}/users", + headers=admin_headers, + json=[], + ) + assert res.status_code == 200 + assert res.json == [] + + +@pytest.mark.asyncio +@pytest.mark.xdist_group("sessions") +async def test_resource_pool_users_put_syncs_authz( + sanic_client: SanicASGITestClient, + admin_headers: dict[str, str], + valid_resource_pool_payload: dict[str, Any], + member_1_user, + member_1_headers, + member_2_user, + member_2_headers, + cluster: KindCluster, +) -> None: + valid_resource_pool_payload["default"] = False + valid_resource_pool_payload["public"] = False + _, res = await create_rp(valid_resource_pool_payload, sanic_client) + assert res.status_code == 201 + rp = res.json + + # Add user1 to the pool + _, res = await sanic_client.post( + f"/api/data/resource_pools/{rp['id']}/users", + headers=admin_headers, + json=[{"id": member_1_user.id}], + ) + assert res.status_code == 201 + + # Verify user1 can access + _, res = await sanic_client.get( + f"/api/data/resource_pools/{rp['id']}", + headers=member_1_headers, + ) + assert res.status_code == 200 + + # PUT replace with only user2 + _, res = await sanic_client.put( + f"/api/data/resource_pools/{rp['id']}/users", + headers=admin_headers, + json=[{"id": member_2_user.id}], + ) + assert res.status_code == 200 + + # Verify user1 can NO LONGER access + _, res = await sanic_client.get( + f"/api/data/resource_pools/{rp['id']}", + headers=member_1_headers, + ) + assert res.status_code == 404 + + # Verify user2 CAN access + _, res = await sanic_client.get( + f"/api/data/resource_pools/{rp['id']}", + headers=member_2_headers, + ) + assert res.status_code == 200 + + +@pytest.mark.asyncio +@pytest.mark.xdist_group("sessions") +async def test_resource_pool_members_put_replaces_prohibited( + sanic_client: SanicASGITestClient, + admin_headers: dict[str, str], + valid_resource_pool_payload: dict[str, Any], + member_1_user, + member_1_headers, + cluster: KindCluster, +) -> None: + valid_resource_pool_payload["default"] = False + valid_resource_pool_payload["public"] = False + _, res = await create_rp(valid_resource_pool_payload, sanic_client) + assert res.status_code == 201 + rp = res.json + + # Add user1 as viewer + _, res = await sanic_client.post( + f"/api/data/resource_pools/{rp['id']}/members", + headers=admin_headers, + json=[{"member_type": "user", "id": member_1_user.id, "relation": "viewer"}], + ) + assert res.status_code == 201 + + # Verify user1 can access + _, res = await sanic_client.get( + f"/api/data/resource_pools/{rp['id']}", + headers=member_1_headers, + ) + assert res.status_code == 200 + + # PUT replace with user1 as prohibited + _, res = await sanic_client.put( + f"/api/data/resource_pools/{rp['id']}/members", + headers=admin_headers, + json=[{"member_type": "user", "id": member_1_user.id, "relation": "prohibited"}], + ) + assert res.status_code == 200 + + # Verify GET /members shows prohibited + _, res = await sanic_client.get( + f"/api/data/resource_pools/{rp['id']}/members", + headers=admin_headers, + ) + assert res.status_code == 200 + members = res.json + assert len(members) == 1 + assert members[0]["relation"] == "prohibited" + + # Verify user1 can NO LONGER access + _, res = await sanic_client.get( + f"/api/data/resource_pools/{rp['id']}", + headers=member_1_headers, + ) + assert res.status_code == 404 + + # PUT replace back with viewer + _, res = await sanic_client.put( + f"/api/data/resource_pools/{rp['id']}/members", + headers=admin_headers, + json=[{"member_type": "user", "id": member_1_user.id, "relation": "viewer"}], + ) + assert res.status_code == 200 + + # Verify user1 can access again + _, res = await sanic_client.get( + f"/api/data/resource_pools/{rp['id']}", + headers=member_1_headers, + ) + assert res.status_code == 200 + + +@pytest.mark.asyncio +@pytest.mark.xdist_group("sessions") +async def test_resource_pool_members_put_mixed_types( + sanic_client: SanicASGITestClient, + admin_headers: dict[str, str], + valid_resource_pool_payload: dict[str, Any], + create_group, + create_project, + member_1_user, + cluster: KindCluster, +) -> None: + valid_resource_pool_payload["default"] = False + valid_resource_pool_payload["public"] = False + _, res = await create_rp(valid_resource_pool_payload, sanic_client) + assert res.status_code == 201 + rp = res.json + + group = await create_group(sanic_client, "test-mixed-group", admin=True) + project = await create_project(sanic_client, "test-mixed-project", admin=True) + + # PUT with user + group + project + _, res = await sanic_client.put( + f"/api/data/resource_pools/{rp['id']}/members", + headers=admin_headers, + json=[ + {"member_type": "user", "id": member_1_user.id, "relation": "viewer"}, + {"member_type": "group", "id": group["id"], "relation": "group_viewer"}, + {"member_type": "project", "id": project["id"], "relation": "project_viewer"}, + ], + ) + assert res.status_code == 200 + members = res.json + assert len(members) == 3 + types = {m["member_type"] for m in members} + assert types == {"user", "group", "project"} + + +@pytest.mark.asyncio +@pytest.mark.xdist_group("sessions") +async def test_resource_pool_members_add_user_via_members( + sanic_client: SanicASGITestClient, + admin_headers: dict[str, str], + valid_resource_pool_payload: dict[str, Any], + member_1_user, + member_1_headers, + cluster: KindCluster, +) -> None: + valid_resource_pool_payload["default"] = False + valid_resource_pool_payload["public"] = False + _, res = await create_rp(valid_resource_pool_payload, sanic_client) + assert res.status_code == 201 + rp = res.json + + # Add user via /members (not legacy /users) + _, res = await sanic_client.post( + f"/api/data/resource_pools/{rp['id']}/members", + headers=admin_headers, + json=[{"member_type": "user", "id": member_1_user.id, "relation": "viewer"}], + ) + assert res.status_code == 201 + + # User should be able to access + _, res = await sanic_client.get( + f"/api/data/resource_pools/{rp['id']}", + headers=member_1_headers, + ) + assert res.status_code == 200 + + +@pytest.mark.asyncio +@pytest.mark.xdist_group("sessions") +async def test_resource_pool_members_get_single_member( + sanic_client: SanicASGITestClient, + admin_headers: dict[str, str], + valid_resource_pool_payload: dict[str, Any], + create_group, + cluster: KindCluster, +) -> None: + valid_resource_pool_payload["default"] = False + valid_resource_pool_payload["public"] = False + _, res = await create_rp(valid_resource_pool_payload, sanic_client) + assert res.status_code == 201 + rp = res.json + + group = await create_group(sanic_client, "test-single-group", admin=True) + + _, res = await sanic_client.post( + f"/api/data/resource_pools/{rp['id']}/members", + headers=admin_headers, + json=[{"member_type": "group", "id": group["id"], "relation": "group_viewer"}], + ) + assert res.status_code == 201 + + # GET single member + _, res = await sanic_client.get( + f"/api/data/resource_pools/{rp['id']}/members/group/{group['id']}", + headers=admin_headers, + ) + assert res.status_code == 200 + assert res.json["member_type"] == "group" + assert res.json["id"] == group["id"] + + # GET non-existent member + _, res = await sanic_client.get( + f"/api/data/resource_pools/{rp['id']}/members/user/nonexistent", + headers=admin_headers, + ) + assert res.status_code == 404 + + +@pytest.mark.asyncio +@pytest.mark.xdist_group("sessions") +async def test_resource_pool_members_add_nonexistent_group( + sanic_client: SanicASGITestClient, + admin_headers: dict[str, str], + valid_resource_pool_payload: dict[str, Any], + cluster: KindCluster, +) -> None: + valid_resource_pool_payload["default"] = False + valid_resource_pool_payload["public"] = False + _, res = await create_rp(valid_resource_pool_payload, sanic_client) + assert res.status_code == 201 + rp = res.json + + fake_group_id = str(ULID()) + _, res = await sanic_client.post( + f"/api/data/resource_pools/{rp['id']}/members", + headers=admin_headers, + json=[{"member_type": "group", "id": fake_group_id, "relation": "group_viewer"}], + ) + assert res.status_code == 404 + + +@pytest.mark.asyncio +@pytest.mark.xdist_group("sessions") +async def test_resource_pool_members_add_nonexistent_project( + sanic_client: SanicASGITestClient, + admin_headers: dict[str, str], + valid_resource_pool_payload: dict[str, Any], + cluster: KindCluster, +) -> None: + valid_resource_pool_payload["default"] = False + valid_resource_pool_payload["public"] = False + _, res = await create_rp(valid_resource_pool_payload, sanic_client) + assert res.status_code == 201 + rp = res.json + + fake_project_id = str(ULID()) + _, res = await sanic_client.post( + f"/api/data/resource_pools/{rp['id']}/members", + headers=admin_headers, + json=[{"member_type": "project", "id": fake_project_id, "relation": "project_viewer"}], + ) + assert res.status_code == 404 + + +@pytest.mark.asyncio +@pytest.mark.xdist_group("sessions") +async def test_resource_pool_members_add_prohibited_group( + sanic_client: SanicASGITestClient, + admin_headers: dict[str, str], + valid_resource_pool_payload: dict[str, Any], + create_group, + cluster: KindCluster, +) -> None: + valid_resource_pool_payload["default"] = False + valid_resource_pool_payload["public"] = False + _, res = await create_rp(valid_resource_pool_payload, sanic_client) + assert res.status_code == 201 + rp = res.json + + group = await create_group(sanic_client, "test-prohibited-group", admin=True) + + _, res = await sanic_client.post( + f"/api/data/resource_pools/{rp['id']}/members", + headers=admin_headers, + json=[{"member_type": "group", "id": group["id"], "relation": "prohibited"}], + ) + assert res.status_code == 422 + + +@pytest.mark.asyncio +@pytest.mark.xdist_group("sessions") +async def test_resource_pool_members_delete_user_inherited_access( + sanic_client: SanicASGITestClient, + admin_headers: dict[str, str], + valid_resource_pool_payload: dict[str, Any], + create_group, + member_1_user, + member_1_headers, + cluster: KindCluster, +) -> None: + valid_resource_pool_payload["default"] = False + valid_resource_pool_payload["public"] = False + _, res = await create_rp(valid_resource_pool_payload, sanic_client) + assert res.status_code == 201 + rp = res.json + + group = await create_group( + sanic_client, + "test-inherited-group", + admin=True, + members=[{"id": member_1_user.id, "role": "viewer"}], + ) + + # Add group to pool + _, res = await sanic_client.post( + f"/api/data/resource_pools/{rp['id']}/members", + headers=admin_headers, + json=[{"member_type": "group", "id": group["id"], "relation": "group_viewer"}], + ) + assert res.status_code == 201 + + # member_1 has inherited access + _, res = await sanic_client.get( + f"/api/data/resource_pools/{rp['id']}", + headers=member_1_headers, + ) + assert res.status_code == 200 + + # Try to DELETE the user directly (they only have inherited access) + _, res = await sanic_client.delete( + f"/api/data/resource_pools/{rp['id']}/members/user/{member_1_user.id}", + headers=admin_headers, + ) + # Should return 204 because user is not a direct member + assert res.status_code == 204 + + # member_1 should STILL have access through the group + _, res = await sanic_client.get( + f"/api/data/resource_pools/{rp['id']}", + headers=member_1_headers, + ) + assert res.status_code == 200 + + +@pytest.mark.asyncio +@pytest.mark.xdist_group("sessions") +async def test_resource_pool_members_put_preserves_admin( + sanic_client: SanicASGITestClient, + admin_headers: dict[str, str], + valid_resource_pool_payload: dict[str, Any], + create_group, + member_1_user, + member_1_headers, + cluster: KindCluster, +) -> None: + valid_resource_pool_payload["default"] = False + valid_resource_pool_payload["public"] = False + _, res = await create_rp(valid_resource_pool_payload, sanic_client) + assert res.status_code == 201 + rp = res.json + + group = await create_group(sanic_client, "test-admin-preserve-group", admin=True) + + # Add user1 as direct viewer + _, res = await sanic_client.post( + f"/api/data/resource_pools/{rp['id']}/members", + headers=admin_headers, + json=[{"member_type": "user", "id": member_1_user.id, "relation": "viewer"}], + ) + assert res.status_code == 201 + + # user1 can access + _, res = await sanic_client.get( + f"/api/data/resource_pools/{rp['id']}", + headers=member_1_headers, + ) + assert res.status_code == 200 + + # Admin can access (inherited admin permission) + _, res = await sanic_client.get( + f"/api/data/resource_pools/{rp['id']}", + headers=admin_headers, + ) + assert res.status_code == 200 + + # PUT replace with only the group + _, res = await sanic_client.put( + f"/api/data/resource_pools/{rp['id']}/members", + headers=admin_headers, + json=[{"member_type": "group", "id": group["id"], "relation": "group_viewer"}], + ) + assert res.status_code == 200 + + # user1 should NO LONGER have access (direct relation removed) + _, res = await sanic_client.get( + f"/api/data/resource_pools/{rp['id']}", + headers=member_1_headers, + ) + assert res.status_code == 404 + + # Admin should STILL have access (inherited admin relation not corrupted) + _, res = await sanic_client.get( + f"/api/data/resource_pools/{rp['id']}", + headers=admin_headers, + ) + assert res.status_code == 200 + + +@pytest.mark.asyncio +@pytest.mark.xdist_group("sessions") +async def test_resource_pool_members_malformed_discriminator( + sanic_client: SanicASGITestClient, + admin_headers: dict[str, str], + valid_resource_pool_payload: dict[str, Any], + cluster: KindCluster, +) -> None: + valid_resource_pool_payload["default"] = False + valid_resource_pool_payload["public"] = False + _, res = await create_rp(valid_resource_pool_payload, sanic_client) + assert res.status_code == 201 + rp = res.json + + # Missing member_type + _, res = await sanic_client.post( + f"/api/data/resource_pools/{rp['id']}/members", + headers=admin_headers, + json=[{"id": "some-id", "relation": "viewer"}], + ) + assert res.status_code == 422 + + # Invalid member_type + _, res = await sanic_client.post( + f"/api/data/resource_pools/{rp['id']}/members", + headers=admin_headers, + json=[{"member_type": "invalid", "id": "some-id", "relation": "viewer"}], + ) + assert res.status_code == 422 + + +@pytest.mark.asyncio +@pytest.mark.xdist_group("sessions") +async def test_resource_pool_members_unknown_relation( + sanic_client: SanicASGITestClient, + admin_headers: dict[str, str], + valid_resource_pool_payload: dict[str, Any], + member_1_user, + cluster: KindCluster, +) -> None: + valid_resource_pool_payload["default"] = False + valid_resource_pool_payload["public"] = False + _, res = await create_rp(valid_resource_pool_payload, sanic_client) + assert res.status_code == 201 + rp = res.json + + # Unknown relation "view" (typo) should be rejected + _, res = await sanic_client.post( + f"/api/data/resource_pools/{rp['id']}/members", + headers=admin_headers, + json=[{"member_type": "user", "id": member_1_user.id, "relation": "view"}], + ) + assert res.status_code == 422 + + +@pytest.mark.asyncio +@pytest.mark.xdist_group("sessions") +async def test_resource_pool_members_delete_nonexistent_group( + sanic_client: SanicASGITestClient, + admin_headers: dict[str, str], + valid_resource_pool_payload: dict[str, Any], + cluster: KindCluster, +) -> None: + valid_resource_pool_payload["default"] = False + valid_resource_pool_payload["public"] = False + _, res = await create_rp(valid_resource_pool_payload, sanic_client) + assert res.status_code == 201 + rp = res.json + + fake_group_id = str(ULID()) + _, res = await sanic_client.delete( + f"/api/data/resource_pools/{rp['id']}/members/group/{fake_group_id}", + headers=admin_headers, + ) + assert res.status_code == 204 + + +@pytest.mark.asyncio +@pytest.mark.xdist_group("sessions") +async def test_resource_pool_members_delete_nonexistent_project( + sanic_client: SanicASGITestClient, + admin_headers: dict[str, str], + valid_resource_pool_payload: dict[str, Any], + cluster: KindCluster, +) -> None: + valid_resource_pool_payload["default"] = False + valid_resource_pool_payload["public"] = False + _, res = await create_rp(valid_resource_pool_payload, sanic_client) + assert res.status_code == 201 + rp = res.json + + fake_project_id = str(ULID()) + _, res = await sanic_client.delete( + f"/api/data/resource_pools/{rp['id']}/members/project/{fake_project_id}", + headers=admin_headers, + ) + assert res.status_code == 204 + From d89b1f7fcc6bc32ba0f41c2b6cd13b5c4fdb9580 Mon Sep 17 00:00:00 2001 From: Salim Kayal Date: Tue, 5 May 2026 22:29:06 +0200 Subject: [PATCH 08/24] squashme: style --- components/renku_data_services/crc/blueprints.py | 5 ++--- components/renku_data_services/crc/db.py | 2 +- .../data_api/test_resource_pools.py | 12 ++++-------- 3 files changed, 7 insertions(+), 12 deletions(-) diff --git a/components/renku_data_services/crc/blueprints.py b/components/renku_data_services/crc/blueprints.py index 3bb229a5b..c429f7a69 100644 --- a/components/renku_data_services/crc/blueprints.py +++ b/components/renku_data_services/crc/blueprints.py @@ -1,6 +1,7 @@ """Compute resource control (CRC) app.""" import asyncio +from contextlib import suppress from dataclasses import dataclass from sanic import HTTPResponse, Request, empty, json @@ -353,14 +354,12 @@ async def _delete( member_id=member_id, member_type=MemberType(member_type), ) - try: + with suppress(errors.MissingResourceError): await self.repo.revoke_resource_pool_members( api_user=user, resource_pool_id=resource_pool_id, members=[identifier], ) - except errors.MissingResourceError: - pass # Already removed or was not part of the pool return HTTPResponse(status=204) return "/resource_pools//members//", ["DELETE"], _delete diff --git a/components/renku_data_services/crc/db.py b/components/renku_data_services/crc/db.py index 1a36dca0f..5cb3f3621 100644 --- a/components/renku_data_services/crc/db.py +++ b/components/renku_data_services/crc/db.py @@ -1299,7 +1299,7 @@ async def _prohibit_resource_pool_users( resource_pool_id: int, user_ids: Collection[str], session: AsyncSession | None = None, - ) -> models.ResourcePoolMembershipChange | None: + ) -> models.ResourcePoolMembershipChange: specs = [(uid, ResourceType.user, Role.PROHIBITED) for uid in user_ids] return self._build_pool_membership_changes(resource_pool_id, specs, Change.ADD) diff --git a/test/bases/renku_data_services/data_api/test_resource_pools.py b/test/bases/renku_data_services/data_api/test_resource_pools.py index 72fc5dbcd..d2af72f22 100644 --- a/test/bases/renku_data_services/data_api/test_resource_pools.py +++ b/test/bases/renku_data_services/data_api/test_resource_pools.py @@ -1707,7 +1707,6 @@ async def test_resource_pool_visibility_toggle( @pytest.mark.asyncio @pytest.mark.xdist_group("sessions") - @pytest.mark.parametrize("total_limit,user_limit", [(1000, 200), (200, 1000), (200, 0), (0, 200)]) async def test_resource_pools_quota_with_partial_usage( sanic_client, @@ -1860,6 +1859,7 @@ async def test_resource_pools_quota_with_no_usage( # usage_hours_total should be 4 hours (200 credits total limit / 50 credits per hour) assert resource_class["usage_hours_total"] == 4.0 + async def test_resource_pool_members_add_group( sanic_client: SanicASGITestClient, admin_headers: dict[str, str], @@ -1916,10 +1916,8 @@ async def test_resource_pool_members_add_group( assert member_1_user.id in user_ids - @pytest.mark.asyncio @pytest.mark.xdist_group("sessions") - async def test_resource_pools_quota_exceeded( sanic_client: SanicASGITestClient, admin_headers: dict[str, str], @@ -2000,6 +1998,7 @@ async def test_resource_pools_quota_exceeded( # usage_hours_total should be 2.0 hours (50 credits/hour * 2 hours = 100 credits limit) assert resource_class["usage_hours_total"] == 2.0 + async def test_resource_pool_members_add_project( sanic_client: SanicASGITestClient, admin_headers: dict[str, str], @@ -2056,10 +2055,8 @@ async def test_resource_pool_members_add_project( assert member_1_user.id in user_ids - @pytest.mark.asyncio @pytest.mark.xdist_group("sessions") - async def test_resource_pools_quota_with_no_limits( sanic_client: SanicASGITestClient, admin_headers: dict[str, str], @@ -2132,6 +2129,7 @@ async def test_resource_pools_quota_with_no_limits( # usage_hours_total should not exist in the response since it's None assert "usage_hours_total" not in resource_class + async def test_resource_pool_members_put_replaces( sanic_client: SanicASGITestClient, admin_headers: dict[str, str], @@ -2174,10 +2172,8 @@ async def test_resource_pool_members_put_replaces( assert members[0]["id"] == group2["id"] - @pytest.mark.asyncio @pytest.mark.xdist_group("sessions") - async def test_resource_pools_quota_with_no_costs( sanic_client: SanicASGITestClient, admin_headers: dict[str, str], @@ -2254,6 +2250,7 @@ async def test_resource_pools_quota_with_no_costs( # usage_hours_total should not exist in the response since it's None assert "usage_hours_total" not in resource_class + async def test_resource_pool_members_delete( sanic_client: SanicASGITestClient, admin_headers: dict[str, str], @@ -2931,4 +2928,3 @@ async def test_resource_pool_members_delete_nonexistent_project( headers=admin_headers, ) assert res.status_code == 204 - From b6a5fa375a1208fba273f3e782be79d6eb07fc8d Mon Sep 17 00:00:00 2001 From: Salim Kayal Date: Mon, 11 May 2026 11:33:43 +0200 Subject: [PATCH 09/24] fix: replace wildcard before-validator with model_validator to avoid discriminator conflict --- components/renku_data_services/crc/apispec_base.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/components/renku_data_services/crc/apispec_base.py b/components/renku_data_services/crc/apispec_base.py index fbe269b82..e2e948324 100644 --- a/components/renku_data_services/crc/apispec_base.py +++ b/components/renku_data_services/crc/apispec_base.py @@ -3,7 +3,7 @@ from pathlib import PurePosixPath from typing import Any -from pydantic import BaseModel, ConfigDict, field_validator +from pydantic import BaseModel, ConfigDict, field_validator, model_validator from ulid import ULID from renku_data_services.session import models @@ -17,7 +17,7 @@ class BaseAPISpec(BaseModel): from_attributes=True, ) - @field_validator("*", mode="before", check_fields=False) + @model_validator(mode="before") @classmethod def serialize_ulid(cls, value: Any) -> Any: """Handle ULIDs.""" From 8bd2b20b233093dbf401b570c47b96a620edb998 Mon Sep 17 00:00:00 2001 From: Salim Kayal Date: Mon, 11 May 2026 11:47:14 +0200 Subject: [PATCH 10/24] fix: bump datamodel-code-generator to ^0.57.0 to fix discriminator on list of oneOf (koxudaxi/datamodel-code-generator#1937) --- components/renku_data_services/crc/apispec.py | 2 +- poetry.lock | 223 ++++++++++++++++-- pyproject.toml | 2 +- 3 files changed, 211 insertions(+), 16 deletions(-) diff --git a/components/renku_data_services/crc/apispec.py b/components/renku_data_services/crc/apispec.py index ceecb220b..901061633 100644 --- a/components/renku_data_services/crc/apispec.py +++ b/components/renku_data_services/crc/apispec.py @@ -1,6 +1,6 @@ # generated by datamodel-codegen: # filename: api.spec.yaml -# timestamp: 2026-05-13T07:18:06+00:00 +# timestamp: 2026-05-13T07:19:34+00:00 from __future__ import annotations diff --git a/poetry.lock b/poetry.lock index 9ca075cbf..9d1b6d536 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 2.3.3 and should not be changed by hand. +# This file is automatically @generated by Poetry 2.3.4 and should not be changed by hand. [[package]] name = "aiofiles" @@ -810,34 +810,38 @@ test-randomorder = ["pytest-randomly"] [[package]] name = "datamodel-code-generator" -version = "0.28.5" +version = "0.57.0" description = "Datamodel Code Generator" optional = false -python-versions = ">=3.9" +python-versions = ">=3.10" groups = ["dev"] files = [ - {file = "datamodel_code_generator-0.28.5-py3-none-any.whl", hash = "sha256:f899c1da5af04b5d5b6e3edbd718c1bf3a00fc4b2fe8210cef609d93a9983e9e"}, - {file = "datamodel_code_generator-0.28.5.tar.gz", hash = "sha256:20e8b817d301d2d0bb15f436e81c97b25ad1c2ef922c99249c2444141ae15a6a"}, + {file = "datamodel_code_generator-0.57.0-py3-none-any.whl", hash = "sha256:d26bf5defe5154493d0aa5a822b7725332b9e9dd2abccc2f8856052286aa83b5"}, + {file = "datamodel_code_generator-0.57.0.tar.gz", hash = "sha256:0eda778ea06eaa476e542a5f1fe1d14cc3bbf686edb33a0ad6151c7d19089906"}, ] [package.dependencies] argcomplete = ">=2.10.1,<4" black = ">=19.10b0" genson = ">=1.2.1,<2" -inflect = ">=4.1,<6" -isort = ">=4.3.21,<7" +inflect = ">=4.1,<8" +isort = ">=4.3.21,<9" jinja2 = ">=2.10.1,<4" -packaging = "*" -pydantic = ">=1.5" +pydantic = [ + {version = ">=2.12,<3", markers = "python_version >= \"3.14\""}, + {version = ">=2,<3", markers = "python_version < \"3.14\""}, +] pyyaml = ">=6.0.1" [package.extras] -all = ["graphql-core (>=3.2.3)", "httpx (>=0.24.1)", "openapi-spec-validator (>=0.2.8,<0.7)", "prance (>=0.18.2)", "pysnooper (>=0.4.1,<2)", "ruff (>=0.9.10)"] +all = ["graphql-core (>=3.2.3)", "httpx (>=0.24.1)", "openapi-spec-validator (>=0.2.8,<0.8)", "prance (>=0.18.2)", "pysnooper (>=0.4.1,<2)", "ruff (>=0.9.10)", "watchfiles (>=1.1)"] debug = ["pysnooper (>=0.4.1,<2)"] graphql = ["graphql-core (>=3.2.3)"] http = ["httpx (>=0.24.1)"] ruff = ["ruff (>=0.9.10)"] -validation = ["openapi-spec-validator (>=0.2.8,<0.7)", "prance (>=0.18.2)"] +ryaml = ["ryaml (>=0.5.1)"] +validation = ["openapi-spec-validator (>=0.2.8,<0.8)", "prance (>=0.18.2)"] +watch = ["watchfiles (>=1.1)"] [[package]] name = "debugpy" @@ -1192,7 +1196,7 @@ description = "Lightweight in-process concurrent programming" optional = false python-versions = ">=3.9" groups = ["main"] -markers = "python_version == \"3.13\" and (platform_machine == \"aarch64\" or platform_machine == \"ppc64le\" or platform_machine == \"x86_64\" or platform_machine == \"amd64\" or platform_machine == \"AMD64\" or platform_machine == \"win32\" or platform_machine == \"WIN32\")" +markers = "platform_machine == \"aarch64\" or platform_machine == \"ppc64le\" or platform_machine == \"x86_64\" or platform_machine == \"amd64\" or platform_machine == \"AMD64\" or platform_machine == \"win32\" or platform_machine == \"WIN32\" or python_version >= \"3.14\"" files = [ {file = "greenlet-3.2.1-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:777c1281aa7c786738683e302db0f55eb4b0077c20f1dc53db8852ffaea0a6b0"}, {file = "greenlet-3.2.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3059c6f286b53ea4711745146ffe5a5c5ff801f62f6c56949446e0f6461f8157"}, @@ -3003,6 +3007,7 @@ description = "Data validation using Python type hints" optional = false python-versions = ">=3.9" groups = ["main", "dev"] +markers = "python_version == \"3.13\"" files = [ {file = "pydantic-2.11.3-py3-none-any.whl", hash = "sha256:a082753436a07f9ba1289c6ffa01cd93db3548776088aa917cc43b63f68fa60f"}, {file = "pydantic-2.11.3.tar.gz", hash = "sha256:7471657138c16adad9322fe3070c0116dd6c3ad8d649300e3cbdfe91f4db4ec3"}, @@ -3019,6 +3024,30 @@ typing-inspection = ">=0.4.0" email = ["email-validator (>=2.0.0)"] timezone = ["tzdata ; python_version >= \"3.9\" and platform_system == \"Windows\""] +[[package]] +name = "pydantic" +version = "2.13.4" +description = "Data validation using Python type hints" +optional = false +python-versions = ">=3.9" +groups = ["main", "dev"] +markers = "python_version >= \"3.14\"" +files = [ + {file = "pydantic-2.13.4-py3-none-any.whl", hash = "sha256:45a282cde31d808236fd7ea9d919b128653c8b38b393d1c4ab335c62924d9aba"}, + {file = "pydantic-2.13.4.tar.gz", hash = "sha256:c40756b57adaa8b1efeeced5c196f3f3b7c435f90e84ea7f443901bec8099ef6"}, +] + +[package.dependencies] +annotated-types = ">=0.6.0" +email-validator = {version = ">=2.0.0", optional = true, markers = "extra == \"email\""} +pydantic-core = "2.46.4" +typing-extensions = ">=4.14.1" +typing-inspection = ">=0.4.2" + +[package.extras] +email = ["email-validator (>=2.0.0)"] +timezone = ["tzdata ; python_version >= \"3.9\" and platform_system == \"Windows\""] + [[package]] name = "pydantic-core" version = "2.33.1" @@ -3026,6 +3055,7 @@ description = "Core functionality for Pydantic validation and serialization" optional = false python-versions = ">=3.9" groups = ["main", "dev"] +markers = "python_version == \"3.13\"" files = [ {file = "pydantic_core-2.33.1-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:3077cfdb6125cc8dab61b155fdd714663e401f0e6883f9632118ec12cf42df26"}, {file = "pydantic_core-2.33.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8ffab8b2908d152e74862d276cf5017c81a2f3719f14e8e3e8d6b83fda863927"}, @@ -3131,6 +3161,140 @@ files = [ [package.dependencies] typing-extensions = ">=4.6.0,<4.7.0 || >4.7.0" +[[package]] +name = "pydantic-core" +version = "2.46.4" +description = "Core functionality for Pydantic validation and serialization" +optional = false +python-versions = ">=3.9" +groups = ["main", "dev"] +markers = "python_version >= \"3.14\"" +files = [ + {file = "pydantic_core-2.46.4-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:a396dcc17e5a0b164dbe026896245a4fa9ff402edca1dff0be3d53a517f74de4"}, + {file = "pydantic_core-2.46.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:da4b951fe36dc7c3a1ccb4e3cd1747c3542b8c9ceede8fc86cae054e764485f5"}, + {file = "pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bb63e0198ca18aad131c089b9204c23079c3afa95487e561f4c522d519e55aba"}, + {file = "pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f47286a97f0bc9b8859519809077b91b2cefe4ae47fcbf5e466a009c1c5d742b"}, + {file = "pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:905a0ed8ea6f2d61c1738835f99b699348d7857379083e5fc497fa0c967a407c"}, + {file = "pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ea793e075b70290d89d8142074262885d3f7da19634845135751bd6344f73b50"}, + {file = "pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:395aebd9183f9d112f569aeb5b2214d1a10a33bec8456447f7fbdfa51d38d4cd"}, + {file = "pydantic_core-2.46.4-cp310-cp310-manylinux_2_31_riscv64.whl", hash = "sha256:b078afbc25f3a1436c7a1d2cd3e322497ee99615ba97c563566fdf46aff1ee01"}, + {file = "pydantic_core-2.46.4-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f747929cf940cddb5b3668a390056ddd5ba2e5010615ea2dcf4f9c4f3ab8791d"}, + {file = "pydantic_core-2.46.4-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:daa27d92c36f24388fe3ad306b174781c747627f134452e4f128ea00ce1fe8c4"}, + {file = "pydantic_core-2.46.4-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:19e51f073cd3df251856a8a4189fbdf1de4012c3ebacfb1884f94f1eb406079f"}, + {file = "pydantic_core-2.46.4-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:c1747f85cee84c26985853c6f3d9bd3e75da5212912443fa111c113b9c246f39"}, + {file = "pydantic_core-2.46.4-cp310-cp310-win32.whl", hash = "sha256:2f84c03c8607173d16b5a854ec68a2f9079ae03237a54fb506d13af47e1d018d"}, + {file = "pydantic_core-2.46.4-cp310-cp310-win_amd64.whl", hash = "sha256:8358a950c8909158e3df31538a7e4edc2d7265a7c54b47f0864d9e5bae9dcebf"}, + {file = "pydantic_core-2.46.4-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:0e96592440881c74a213e5ad528e2b24d3d4f940de2766bed9010ab1d9e51594"}, + {file = "pydantic_core-2.46.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e0d65b8c354be7fb5f720c3caa8bc940bc2d20ce749c8e06135f07f8ed95dd7c"}, + {file = "pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7bfb192b3f4b9e8a89b6277b6ce787564f62cfd272055f6e685726b111dc7826"}, + {file = "pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9037063db01f09b09e237c282b6792bd4da634b5402c4e7f0c61effed7701a04"}, + {file = "pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fc010ab034c8c7452522748bf937df58020d256ccae0874463d1f4d01758af8e"}, + {file = "pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8c5dac79fa1614d1e06ca695109c6105923bd9c7d1d6c918d4e637b7e6b32fd3"}, + {file = "pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f9fa868638bf362d3d138ea55829cefb3d5f4b0d7f142234382a15e2485dbec4"}, + {file = "pydantic_core-2.46.4-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:17299feefe090f2caa5b8e37222bb5f663e4935a8bfa6931d4102e5df1a9f398"}, + {file = "pydantic_core-2.46.4-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4c63ebc82684aa89d9a3bcbd13d515b3be44250dc68dd3bd81526c1cb31286c3"}, + {file = "pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:aaa2a54443eff1950ba5ddc6b6ccda0d9c84a364276a62f969bdf2a390650848"}, + {file = "pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:18e5ceec2ab67e6d5f1a9085e5a24c9c4e2ac4545730bfe668680bca05e555f3"}, + {file = "pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:a0f62d0a58f4e7da165457e995725421e0064f2255d8eccebc49f41bbc23b109"}, + {file = "pydantic_core-2.46.4-cp311-cp311-win32.whl", hash = "sha256:041bde0a48fd37cf71cab1c9d56d3e8625a3793fef1f7dd232b3ff37e978ecda"}, + {file = "pydantic_core-2.46.4-cp311-cp311-win_amd64.whl", hash = "sha256:6f2eeda33a839975441c86a4119e1383c50b47faf0cbb5176985565c6bb02c33"}, + {file = "pydantic_core-2.46.4-cp311-cp311-win_arm64.whl", hash = "sha256:14f4c5d6db102bd796a627bbb3a17b4cf4574b9ae861d8b7c9a9661c6dd3362d"}, + {file = "pydantic_core-2.46.4-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:3245406455a5d98187ec35530fd772b1d799b26667980872c8d4614991e2c4a2"}, + {file = "pydantic_core-2.46.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:962ccbab7b642487b1d8b7df90ef677e03134cf1fd8880bf698649b22a69371f"}, + {file = "pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8233f2947cf85404441fd7e0085f53b10c93e0ee78611099b5c7237e36aacbf7"}, + {file = "pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3a233125ac121aa3ffba9a2b59edfc4a985a76092dc8279586ab4b71390875e7"}, + {file = "pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5b712b53160b79a5850310b912a5ef8e57e56947c8ad690c227f5c9d7e561712"}, + {file = "pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9401557acd873c3a7f3eb9383edef8ac4968f9510e340f4808d427e75667e7b4"}, + {file = "pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:926c9541b14b12b1681dca8a0b75feb510b06c6341b70a8e500c2fdcff837cce"}, + {file = "pydantic_core-2.46.4-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:56cb4851bcaf3d117eddcef4fe66afd750a50274b0da8e22be256d10e5611987"}, + {file = "pydantic_core-2.46.4-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c68fcd102d71ea85c5b2dfac3f4f8476eff42a9e078fd5faefff6d145063536b"}, + {file = "pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:b2f69dec1725e79a012d920df1707de5caf7ed5e08f3be4435e25803efc47458"}, + {file = "pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:8d0820e8192167f80d88d64038e609c31452eeca865b4e1d9950a27a4609b00b"}, + {file = "pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:fbdb89b3e1c94a30cc5edfce477c6e6a5dc4d8f84665b455c27582f211a1c72c"}, + {file = "pydantic_core-2.46.4-cp312-cp312-win32.whl", hash = "sha256:9aa768456404a8bf48a4406685ac2bec8e72b62c69313734fa3b73cf33b3a894"}, + {file = "pydantic_core-2.46.4-cp312-cp312-win_amd64.whl", hash = "sha256:e9c26f834c65f5752f3f06cb08cb86a913ceb7274d0db6e267808a708b46bc89"}, + {file = "pydantic_core-2.46.4-cp312-cp312-win_arm64.whl", hash = "sha256:4fc73cb559bdb54b1134a706a2802a4cddd27a0633f5abb7e53056268751ac6a"}, + {file = "pydantic_core-2.46.4-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:5d5902252db0d3cedf8d4a1bc68f70eeb430f7e4c7104c8c476753519b423008"}, + {file = "pydantic_core-2.46.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c94f0688e7b8d0a67abf40e57a7eaaecd17cc9586706a31b76c031f63df052b4"}, + {file = "pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f027324c56cd5406ca49c124b0db10e56c69064fec039acc571c29020cc87c76"}, + {file = "pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e739fee756ba1010f8bcccb534252e85a35fe45ae92c295a06059ce58b74ccd3"}, + {file = "pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9d56801be94b86a9da183e5f3766e6310752b99ff647e38b09a9500d88e46e76"}, + {file = "pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2412e734dcb48da14d4e4006b82b46b74f2518b8a26ee7e58c6844a6cd6d03c4"}, + {file = "pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9551187363ffc0de2a00b2e47c25aeaeb1020b69b668762966df15fc5659dd5a"}, + {file = "pydantic_core-2.46.4-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:0186750b482eefa11d7f435892b09c5c606193ef3375bcf94aa00ae6bfb66262"}, + {file = "pydantic_core-2.46.4-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5855698a4856556d86e8e6cd8434bc3ac0314ee8e12089ae0e143f64c6256e4e"}, + {file = "pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:cbaf13819775b7f769bf4a1f066cb6df7a28d4480081a589828ef190226881cd"}, + {file = "pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:633147d34cf4550417f12e2b1a0383973bdf5cdfde212cb09e9a581cf10820be"}, + {file = "pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:82cf5301172168103724d49a1444d3378cb20cdee30b116a1bd6031236298a5d"}, + {file = "pydantic_core-2.46.4-cp313-cp313-win32.whl", hash = "sha256:9fa8ae11da9e2b3126c6426f147e0fba88d96d65921799bb30c6abd1cb2c97fb"}, + {file = "pydantic_core-2.46.4-cp313-cp313-win_amd64.whl", hash = "sha256:6b3ace8194b0e5204818c92802dcdca7fc6d88aabbb799d7c795540d9cd6d292"}, + {file = "pydantic_core-2.46.4-cp313-cp313-win_arm64.whl", hash = "sha256:184c081504d17f1c1066e430e117142b2c77d9448a97f7b65c6ac9fd9aee238d"}, + {file = "pydantic_core-2.46.4-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:428e04521a40150c85216fc8b85e8d39fece235a9cf5e383761238c7fa9b96fb"}, + {file = "pydantic_core-2.46.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:23ace664830ee0bfe014a0c7bc248b1f7f25ed7ad103852c317624a1083af462"}, + {file = "pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce5c1d2a8b27468f433ca974829c44060b8097eedc39933e3c206a90ee49c4a9"}, + {file = "pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7283d57845ecf5a163403eb0702dfc220cc4fbdd18919cb5ccea4f95ee1cdab4"}, + {file = "pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8daafc69c93ee8a0204506a3b6b30f586ef54028f52aeeeb5c4cfc5184fd5914"}, + {file = "pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cd2213145bcc2ba85884d0ac63d222fece9209678f77b9b4d76f054c561adb28"}, + {file = "pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7a5f930472650a82629163023e630d160863fce524c616f4e5186e5de9d9a49b"}, + {file = "pydantic_core-2.46.4-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:c1b3f518abeca3aa13c712fd202306e145abf59a18b094a6bafb2d2bbf59192c"}, + {file = "pydantic_core-2.46.4-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1a7dd0b3ee80d90150e3495a3a13ac34dbcbfd4f012996a6a1d8900e91b5c0fb"}, + {file = "pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:3fb702cd90b0446a3a1c5e470bfa0dd23c0233b676a9099ddcc964fa6ca13898"}, + {file = "pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:b8458003118a712e66286df6a707db01c52c0f52f7db8e4a38f0da1d3b94fc4e"}, + {file = "pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:372429a130e469c9cd698925ce5fc50940b7a1336b0d82038e63d5bbc4edc519"}, + {file = "pydantic_core-2.46.4-cp314-cp314-win32.whl", hash = "sha256:85bb3611ff1802f3ee7fdd7dbff26b56f343fb432d57a4728fdd49b6ef35e2f4"}, + {file = "pydantic_core-2.46.4-cp314-cp314-win_amd64.whl", hash = "sha256:811ff8e9c313ab425368bcbb36e5c4ebd7108c2bbf4e4089cfbb0b01eff63fac"}, + {file = "pydantic_core-2.46.4-cp314-cp314-win_arm64.whl", hash = "sha256:bfec22eab3c8cc2ceec0248aec886624116dc079afa027ecc8ad4a7e62010f8a"}, + {file = "pydantic_core-2.46.4-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:af8244b2bef6aaad6d92cda81372de7f8c8d36c9f0c3ea36e827c60e7d9467a0"}, + {file = "pydantic_core-2.46.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5a4330cdbc57162e4b3aa303f588ba752257694c9c9be3e7ebb11b4aca659b5d"}, + {file = "pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:29c61fc04a3d840155ff08e475a04809278972fe6aef51e2720554e96367e34b"}, + {file = "pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c50f2528cf200c5eed56faf3f4e22fcd5f38c157a8b78576e6ba3168ec35f000"}, + {file = "pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0cbe8b01f948de4286c74cdd6c667aceb38f5c1e26f0693b3983d9d74887c65e"}, + {file = "pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:617d7e2ca7dcb8c5cf6bcb8c59b8832c94b36196bbf1cbd1bfb56ed341905edd"}, + {file = "pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7027560ee92211647d0d34e3f7cd6f50da56399d26a9c8ad0da286d3869a53f3"}, + {file = "pydantic_core-2.46.4-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:f99626688942fb746e545232e7726926f3be91b5975f8b55327665fafda991c7"}, + {file = "pydantic_core-2.46.4-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fc3e9034a63de20e15e8ade85358bc6efc614008cab72898b4b4952bea0509ff"}, + {file = "pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:97e7cf2be5c77b7d1a9713a05605d49460d02c6078d38d8bef3cbe323c548424"}, + {file = "pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:3bf92c5d0e00fefaab325a4d27828fe6b6e2a21848686b5b60d2d9eeb09d76c6"}, + {file = "pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:3ecbc122d18468d06ca279dc26a8c2e2d5acb10943bb35e36ae92096dc3b5565"}, + {file = "pydantic_core-2.46.4-cp314-cp314t-win32.whl", hash = "sha256:e846ae7835bf0703ae43f534ab79a867146dadd59dc9ca5c8b53d5c8f7c9ef02"}, + {file = "pydantic_core-2.46.4-cp314-cp314t-win_amd64.whl", hash = "sha256:2108ba5c1c1eca18030634489dc544844144ee36357f2f9f780b93e7ddbb44b5"}, + {file = "pydantic_core-2.46.4-cp314-cp314t-win_arm64.whl", hash = "sha256:4fcbe087dbc2068af7eda3aa87634eba216dbda64d1ae73c8684b621d33f6596"}, + {file = "pydantic_core-2.46.4-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:fd8b3d9fd264be37976686c7f65cd52a83f5e84f4bfd2adf9c1d469676bbb6ae"}, + {file = "pydantic_core-2.46.4-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:9f444c499b3eefd3a92e348059471ea0c3a6e303d9c1cec09fa748fd9f895201"}, + {file = "pydantic_core-2.46.4-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3447661d99f75a3683a4cf5c87da72f2161964611864dbbeac7fbb118bb4bfc0"}, + {file = "pydantic_core-2.46.4-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8b9bab013d1c7a79d3501ff86d0bc9c31bf587db4551677b96bec07df78c6b15"}, + {file = "pydantic_core-2.46.4-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d995260fdf4e1db774581b4900e0f832abe3c7c84996726bbc161b19c8f29e76"}, + {file = "pydantic_core-2.46.4-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f13a646d65d09fbf1bc6b3a9635d30095c8e7e5cc419ff35ecc563c5fd04cd49"}, + {file = "pydantic_core-2.46.4-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:432c179df7874eeb73307aad2df0755e1ae0efa61ff0ea89b93e194411ae3928"}, + {file = "pydantic_core-2.46.4-cp39-cp39-manylinux_2_31_riscv64.whl", hash = "sha256:e68b7a074f65a2fd746c52a7ce6142ab7006074ac269ace0c25cd8ba171f8066"}, + {file = "pydantic_core-2.46.4-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4a05d69cba51d852c5c3e92758653245a50c0b646ced0cf05bd793ed592839d6"}, + {file = "pydantic_core-2.46.4-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:228ee9bae8bef5b1e97ec58302f80357c37199e0d0a99174e138d28e6957b9d9"}, + {file = "pydantic_core-2.46.4-cp39-cp39-musllinux_1_1_armv7l.whl", hash = "sha256:10e17cbb10a330363733efc4d7c4d0dd827ac0909b8f6a6542298fed1ea62f29"}, + {file = "pydantic_core-2.46.4-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:91a06d2e259ecfbd8c901d70c3c507900458498142b3026a296b7de4d1322cc9"}, + {file = "pydantic_core-2.46.4-cp39-cp39-win32.whl", hash = "sha256:d80ee3d731373b24cebbc10d689ca4ee1875caf0d5703a245db18efd4dd37fc1"}, + {file = "pydantic_core-2.46.4-cp39-cp39-win_amd64.whl", hash = "sha256:3be77f45df024d789a672ae34f8b06fb346c4f9f46ea714956660ea4862e89ac"}, + {file = "pydantic_core-2.46.4-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:14d4edf427bdcf950a8a02d7cb44a08614388dd6e1bdcbf4f67504fa7887da9c"}, + {file = "pydantic_core-2.46.4-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:0ce40cd7b21210e99342afafbd4d0f76d784eb5b1d60f3bdc566be4983c6c73b"}, + {file = "pydantic_core-2.46.4-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:90884113d8b48f760e9587002789ddd741e76ab9f89518cd1e43b1f1a52ec44b"}, + {file = "pydantic_core-2.46.4-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:66ce7632c22d837c95301830e111ad0128a32b8207533b60896a96c4915192ea"}, + {file = "pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:1d8ba486450b14f3b1d63bc521d410ec7565e52f887b9fb671791886436a42f7"}, + {file = "pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:3009f12e4e90b7f88b4f9adb1b0c4a3d58fe7820f3238c190047209d148026df"}, + {file = "pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ad785e92e6dc634c21555edc8bd6b64957ab844541bcb96a1366c202951ae526"}, + {file = "pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:00c603d540afdd6b80eb39f078f33ebd46211f02f33e34a32d9f053bba711de0"}, + {file = "pydantic_core-2.46.4-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:0c563b08bca408dc7f65f700633d8442fffb2421fc47b8101377e9fd65051ff0"}, + {file = "pydantic_core-2.46.4-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:db06ffe51636ffe9ca531fe9023dd64bdd794be8754cb5df57c5498ae5b518a7"}, + {file = "pydantic_core-2.46.4-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:133878133d271ade3d41d1bfb2a45ec38dbdbda40bc065921c6b04e4630127e2"}, + {file = "pydantic_core-2.46.4-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:9bc519fbf2b7578398853d815009ae5e4d4603d12f4e3f91da8c06852d3da3e9"}, + {file = "pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:c7a7bd4e39e8e4c12c39cd480356842b6a8a06e41b23a55a5e3e191718838ddf"}, + {file = "pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:d396ec2b979760aaf3218e76c24e65bd0aca24983298653b3a9d7a45f9e47b30"}, + {file = "pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:86e1a4418c6cd97d60c95c71164158eaf7324fae7b0923264016baa993eba6fc"}, + {file = "pydantic_core-2.46.4-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:d51026d73fcfd93610abc7b27789c26b313920fcfb20e27462d74a7f8b06e983"}, + {file = "pydantic_core-2.46.4.tar.gz", hash = "sha256:62f875393d7f270851f20523dd2e29f082bcc82292d66db2b64ea71f64b6e1c1"}, +] + +[package.dependencies] +typing-extensions = ">=4.14.1" + [[package]] name = "pygments" version = "2.19.1" @@ -4310,7 +4474,7 @@ files = [ ] [package.dependencies] -greenlet = {version = ">=1", optional = true, markers = "python_version < \"3.14\" and (platform_machine == \"aarch64\" or platform_machine == \"ppc64le\" or platform_machine == \"x86_64\" or platform_machine == \"amd64\" or platform_machine == \"AMD64\" or platform_machine == \"win32\" or platform_machine == \"WIN32\") or extra == \"asyncio\""} +greenlet = {version = ">=1", optional = true, markers = "extra == \"asyncio\""} typing-extensions = ">=4.6.0" [package.extras] @@ -4608,11 +4772,25 @@ description = "Backported and Experimental Type Hints for Python 3.8+" optional = false python-versions = ">=3.8" groups = ["main", "dev"] +markers = "python_version == \"3.13\"" files = [ {file = "typing_extensions-4.13.2-py3-none-any.whl", hash = "sha256:a439e7c04b49fec3e5d3e2beaa21755cadbbdc391694e28ccdd36ca4a1408f8c"}, {file = "typing_extensions-4.13.2.tar.gz", hash = "sha256:e6c81219bd689f51865d9e372991c540bda33a0379d5573cddb9a3a23f7caaef"}, ] +[[package]] +name = "typing-extensions" +version = "4.15.0" +description = "Backported and Experimental Type Hints for Python 3.9+" +optional = false +python-versions = ">=3.9" +groups = ["main", "dev"] +markers = "python_version >= \"3.14\"" +files = [ + {file = "typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548"}, + {file = "typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466"}, +] + [[package]] name = "typing-inspection" version = "0.4.0" @@ -4620,6 +4798,7 @@ description = "Runtime typing introspection tools" optional = false python-versions = ">=3.9" groups = ["main", "dev"] +markers = "python_version == \"3.13\"" files = [ {file = "typing_inspection-0.4.0-py3-none-any.whl", hash = "sha256:50e72559fcd2a6367a19f7a7e610e6afcb9fac940c650290eed893d61386832f"}, {file = "typing_inspection-0.4.0.tar.gz", hash = "sha256:9765c87de36671694a67904bf2c96e395be9c6439bb6c87b5142569dcdd65122"}, @@ -4628,6 +4807,22 @@ files = [ [package.dependencies] typing-extensions = ">=4.12.0" +[[package]] +name = "typing-inspection" +version = "0.4.2" +description = "Runtime typing introspection tools" +optional = false +python-versions = ">=3.9" +groups = ["main", "dev"] +markers = "python_version >= \"3.14\"" +files = [ + {file = "typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7"}, + {file = "typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464"}, +] + +[package.dependencies] +typing-extensions = ">=4.12.0" + [[package]] name = "tzdata" version = "2025.2" @@ -5312,4 +5507,4 @@ cffi = ["cffi (>=1.11)"] [metadata] lock-version = "2.1" python-versions = "^3.13" -content-hash = "13fdf0da495d1a4ad63f7663c79a6badb451be3692b551d84184006a5b9f981d" +content-hash = "1ada1983ea2395377c0054b76bd8deb4878b30b4d12ebfb551d81b6a95201a4c" diff --git a/pyproject.toml b/pyproject.toml index 18c9f7d0f..21763ac61 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -111,7 +111,7 @@ pytest-mock = "^3.14.0" uvloop = "^0.21.0" syrupy = "^4.8.2" ruamel-yaml = "^0.18.14" -datamodel-code-generator = "^0.28.4" +datamodel-code-generator = "^0.57.0" [build-system] requires = ["poetry-core"] From d92f5f867c9f4c1bc04576f6f37e64364485af35 Mon Sep 17 00:00:00 2001 From: Salim Kayal Date: Mon, 11 May 2026 11:48:14 +0200 Subject: [PATCH 11/24] fix: remove sessions for authz decorated functions --- components/renku_data_services/crc/db.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/components/renku_data_services/crc/db.py b/components/renku_data_services/crc/db.py index 5cb3f3621..a729d8e13 100644 --- a/components/renku_data_services/crc/db.py +++ b/components/renku_data_services/crc/db.py @@ -1203,12 +1203,12 @@ async def update_resource_pool_users( ResourcePoolMemberIdentifier(member_id=uid, member_type=MemberType.USER) for uid in to_remove_ids ] - await self._revoke_resource_pool_members(api_user, resource_pool_id, to_remove, session=session) + await self._revoke_resource_pool_members(api_user, resource_pool_id, to_remove) if to_add_ids: to_add = [ ResourcePoolMemberIdentifier(member_id=uid, member_type=MemberType.USER) for uid in to_add_ids ] - await self._grant_resource_pool_members(api_user, resource_pool_id, to_add, session=session) + await self._grant_resource_pool_members(api_user, resource_pool_id, to_add) return [usr.dump() for usr in rp.users] @staticmethod @@ -1443,11 +1443,11 @@ async def update_resource_pool_members( to_add = [m for m in members if (m.member_type, m.member_id, m.relation) not in current_set] if to_remove: - await self._revoke_resource_pool_members(api_user, resource_pool_id, to_remove, session=session) + await self._revoke_resource_pool_members(api_user, resource_pool_id, to_remove) if to_add: - await self._grant_resource_pool_members(api_user, resource_pool_id, to_add, session=session) + await self._grant_resource_pool_members(api_user, resource_pool_id, to_add) else: - await self._grant_resource_pool_members(api_user, resource_pool_id, members, session=session) + await self._grant_resource_pool_members(api_user, resource_pool_id, members) return await self.get_resource_pool_members(api_user, resource_pool_id) From 72ab86b01844380212be5bb7dcaccf242f95356b Mon Sep 17 00:00:00 2001 From: Salim Kayal Date: Mon, 11 May 2026 11:49:05 +0200 Subject: [PATCH 12/24] fix: Specify ULID field conversion --- components/renku_data_services/crc/apispec_base.py | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/components/renku_data_services/crc/apispec_base.py b/components/renku_data_services/crc/apispec_base.py index e2e948324..688918170 100644 --- a/components/renku_data_services/crc/apispec_base.py +++ b/components/renku_data_services/crc/apispec_base.py @@ -3,7 +3,7 @@ from pathlib import PurePosixPath from typing import Any -from pydantic import BaseModel, ConfigDict, field_validator, model_validator +from pydantic import BaseModel, ConfigDict, field_validator from ulid import ULID from renku_data_services.session import models @@ -17,13 +17,11 @@ class BaseAPISpec(BaseModel): from_attributes=True, ) - @model_validator(mode="before") + @field_validator("id", mode="before", check_fields=False) @classmethod - def serialize_ulid(cls, value: Any) -> Any: - """Handle ULIDs.""" - if isinstance(value, ULID): - return str(value) - return value + def serialize_id(cls, v: ULID) -> str: + """Custom serializer that can handle ULIDs for id.""" + return str(v) @field_validator("project_id", mode="before", check_fields=False) @classmethod From fbf06aa38a0b273fe082e4c5eef04c36a92ff189 Mon Sep 17 00:00:00 2001 From: Salim Kayal Date: Mon, 11 May 2026 14:53:57 +0200 Subject: [PATCH 13/24] squashme: style checks --- components/renku_data_services/crc/apispec_base.py | 1 - 1 file changed, 1 deletion(-) diff --git a/components/renku_data_services/crc/apispec_base.py b/components/renku_data_services/crc/apispec_base.py index 688918170..c03e18838 100644 --- a/components/renku_data_services/crc/apispec_base.py +++ b/components/renku_data_services/crc/apispec_base.py @@ -1,7 +1,6 @@ """Base models for API specifications.""" from pathlib import PurePosixPath -from typing import Any from pydantic import BaseModel, ConfigDict, field_validator from ulid import ULID From 66000b852d1150356c2bd9bc201c8e2ed881eb60 Mon Sep 17 00:00:00 2001 From: Salim Kayal Date: Wed, 13 May 2026 09:04:01 +0200 Subject: [PATCH 14/24] feat: validate new output fields for PoolMemberResponse --- .../data_api/test_resource_pools.py | 30 +++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/test/bases/renku_data_services/data_api/test_resource_pools.py b/test/bases/renku_data_services/data_api/test_resource_pools.py index d2af72f22..8b1fd452c 100644 --- a/test/bases/renku_data_services/data_api/test_resource_pools.py +++ b/test/bases/renku_data_services/data_api/test_resource_pools.py @@ -1887,6 +1887,8 @@ async def test_resource_pool_members_add_group( json=member_payload, ) assert res.status_code == 201 + assert res.json[0]["slug"] == "test-pool-group" + assert res.json[0]["name"] == "test-pool-group" # GET /members should return the group _, res = await sanic_client.get( @@ -1897,6 +1899,8 @@ async def test_resource_pool_members_add_group( members = res.json group_members = [m for m in members if m.get("member_type") == "group" and m.get("id") == group["id"]] assert len(group_members) == 1 + assert group_members[0]["slug"] == "test-pool-group" + assert group_members[0]["name"] == "test-pool-group" # member_1 (in the group) should now be able to access the pool _, res = await sanic_client.get( @@ -2026,6 +2030,8 @@ async def test_resource_pool_members_add_project( json=member_payload, ) assert res.status_code == 201 + assert res.json[0]["namespace"] == "admin.doe/test-pool-project" + assert res.json[0]["name"] == "test-pool-project" # GET /members should return the project _, res = await sanic_client.get( @@ -2036,6 +2042,8 @@ async def test_resource_pool_members_add_project( members = res.json project_members = [m for m in members if m.get("member_type") == "project" and m.get("id") == project["id"]] assert len(project_members) == 1 + assert project_members[0]["namespace"] == "admin.doe/test-pool-project" + assert project_members[0]["name"] == "test-pool-project" # member_1 (in the project) should now be able to access the pool _, res = await sanic_client.get( @@ -2161,6 +2169,8 @@ async def test_resource_pool_members_put_replaces( json=[{"member_type": "group", "id": group2["id"], "relation": "group_viewer"}], ) assert res.status_code == 200 + assert res.json[0]["slug"] == "test-group-2" + assert res.json[0]["name"] == "test-group-2" _, res = await sanic_client.get( f"/api/data/resource_pools/{rp['id']}/members", @@ -2170,6 +2180,8 @@ async def test_resource_pool_members_put_replaces( members = res.json assert len(members) == 1 assert members[0]["id"] == group2["id"] + assert members[0]["slug"] == "test-group-2" + assert members[0]["name"] == "test-group-2" @pytest.mark.asyncio @@ -2326,6 +2338,7 @@ async def test_resource_pool_members_get_includes_relation( json=[{"member_type": "user", "id": user["id"], "relation": "viewer"}], ) assert res.status_code == 201 + assert "email" in res.json[0] _, res = await sanic_client.get( f"/api/data/resource_pools/{rp['id']}/members", @@ -2337,6 +2350,7 @@ async def test_resource_pool_members_get_includes_relation( assert members[0]["member_type"] == "user" assert members[0]["id"] == user["id"] assert members[0]["relation"] == "viewer" + assert "email" in members[0] @pytest.mark.asyncio @@ -2497,6 +2511,7 @@ async def test_resource_pool_members_put_replaces_prohibited( json=[{"member_type": "user", "id": member_1_user.id, "relation": "prohibited"}], ) assert res.status_code == 200 + assert "email" in res.json[0] # Verify GET /members shows prohibited _, res = await sanic_client.get( @@ -2507,6 +2522,7 @@ async def test_resource_pool_members_put_replaces_prohibited( members = res.json assert len(members) == 1 assert members[0]["relation"] == "prohibited" + assert "email" in members[0] # Verify user1 can NO LONGER access _, res = await sanic_client.get( @@ -2566,6 +2582,15 @@ async def test_resource_pool_members_put_mixed_types( assert len(members) == 3 types = {m["member_type"] for m in members} assert types == {"user", "group", "project"} + for m in members: + if m["member_type"] == "user": + assert "email" in m + elif m["member_type"] == "group": + assert m["slug"] == "test-mixed-group" + assert m["name"] == "test-mixed-group" + elif m["member_type"] == "project": + assert m["namespace"] == "admin.doe/test-mixed-project" + assert m["name"] == "test-mixed-project" @pytest.mark.asyncio @@ -2591,6 +2616,7 @@ async def test_resource_pool_members_add_user_via_members( json=[{"member_type": "user", "id": member_1_user.id, "relation": "viewer"}], ) assert res.status_code == 201 + assert "email" in res.json[0] # User should be able to access _, res = await sanic_client.get( @@ -2623,6 +2649,8 @@ async def test_resource_pool_members_get_single_member( json=[{"member_type": "group", "id": group["id"], "relation": "group_viewer"}], ) assert res.status_code == 201 + assert res.json[0]["slug"] == "test-single-group" + assert res.json[0]["name"] == "test-single-group" # GET single member _, res = await sanic_client.get( @@ -2632,6 +2660,8 @@ async def test_resource_pool_members_get_single_member( assert res.status_code == 200 assert res.json["member_type"] == "group" assert res.json["id"] == group["id"] + assert res.json["slug"] == "test-single-group" + assert res.json["name"] == "test-single-group" # GET non-existent member _, res = await sanic_client.get( From 8efada9e88a882b694bb15c74294a7d08611d1ad Mon Sep 17 00:00:00 2001 From: Salim Kayal Date: Wed, 13 May 2026 09:04:51 +0200 Subject: [PATCH 15/24] feat: add PoolMemberResponse to api spec --- .../renku_data_services/crc/api.spec.yaml | 105 +++++++++++++++++- components/renku_data_services/crc/apispec.py | 92 ++++++++++++++- 2 files changed, 194 insertions(+), 3 deletions(-) diff --git a/components/renku_data_services/crc/api.spec.yaml b/components/renku_data_services/crc/api.spec.yaml index 66c0d45ce..e8ef0f56a 100644 --- a/components/renku_data_services/crc/api.spec.yaml +++ b/components/renku_data_services/crc/api.spec.yaml @@ -787,7 +787,7 @@ paths: content: "application/json": schema: - $ref: "#/components/schemas/PoolMembers" + $ref: "#/components/schemas/PoolMembersResponse" "404": description: The resource pool does not exist content: @@ -888,7 +888,7 @@ paths: content: "application/json": schema: - $ref: "#/components/schemas/PoolMember" + $ref: "#/components/schemas/PoolMemberResponse" "404": description: The member does not belong to the resource pool, or the resource pool or member do not exist content: @@ -1810,6 +1810,107 @@ components: items: $ref: "#/components/schemas/PoolMember" uniqueItems: true + PoolMemberUserResponse: + type: object + additionalProperties: false + required: + - member_type + - id + - relation + - email + properties: + member_type: + type: string + enum: [user] + id: + $ref: "#/components/schemas/UserId" + relation: + type: string + enum: [viewer, prohibited] + email: + type: string + example: + member_type: user + id: "some-random-keycloak-id" + relation: viewer + email: "user@example.com" + PoolMemberGroupResponse: + type: object + additionalProperties: false + required: + - member_type + - id + - relation + - slug + - name + properties: + member_type: + type: string + enum: [group] + id: + type: string + description: Group id (ULID) + example: "01ARZ3NDEKTSV4RRFFQ69G5FAV" + relation: + type: string + enum: [group_viewer] + slug: + type: string + name: + type: string + example: + member_type: group + id: "01ARZ3NDEKTSV4RRFFQ69G5FAV" + relation: group_viewer + slug: "my-group" + name: "My Group" + PoolMemberProjectResponse: + type: object + additionalProperties: false + required: + - member_type + - id + - relation + - namespace + - name + properties: + member_type: + type: string + enum: [project] + id: + type: string + description: Project id (ULID) + example: "01ARZ3NDEKTSV4RRFFQ69G5FAW" + relation: + type: string + enum: [project_viewer] + namespace: + type: string + description: Full project namespace path (e.g. user/project) + name: + type: string + example: + member_type: project + id: "01ARZ3NDEKTSV4RRFFQ69G5FAW" + relation: project_viewer + namespace: "user.doe/my-project" + name: "My Project" + PoolMemberResponse: + oneOf: + - $ref: "#/components/schemas/PoolMemberUserResponse" + - $ref: "#/components/schemas/PoolMemberGroupResponse" + - $ref: "#/components/schemas/PoolMemberProjectResponse" + discriminator: + propertyName: member_type + mapping: + user: "#/components/schemas/PoolMemberUserResponse" + group: "#/components/schemas/PoolMemberGroupResponse" + project: "#/components/schemas/PoolMemberProjectResponse" + PoolMembersResponse: + type: array + items: + $ref: "#/components/schemas/PoolMemberResponse" + uniqueItems: true QuotaPatch: type: object additionalProperties: false diff --git a/components/renku_data_services/crc/apispec.py b/components/renku_data_services/crc/apispec.py index 901061633..fdb6e47a3 100644 --- a/components/renku_data_services/crc/apispec.py +++ b/components/renku_data_services/crc/apispec.py @@ -1,6 +1,6 @@ # generated by datamodel-codegen: # filename: api.spec.yaml -# timestamp: 2026-05-13T07:19:34+00:00 +# timestamp: 2026-05-13T07:20:14+00:00 from __future__ import annotations @@ -58,6 +58,59 @@ class PoolMemberProject(BaseAPISpec): relation: Relation2 +class MemberType3(StrEnum): + user = "user" + + +class Relation3(StrEnum): + viewer = "viewer" + prohibited = "prohibited" + + +class MemberType4(StrEnum): + group = "group" + + +class Relation4(StrEnum): + group_viewer = "group_viewer" + + +class PoolMemberGroupResponse(BaseAPISpec): + model_config = ConfigDict( + extra="forbid", + ) + member_type: Literal["group"] = "group" + id: str = Field( + ..., description="Group id (ULID)", examples=["01ARZ3NDEKTSV4RRFFQ69G5FAV"] + ) + relation: Relation4 + slug: str + name: str + + +class MemberType5(StrEnum): + project = "project" + + +class Relation5(StrEnum): + project_viewer = "project_viewer" + + +class PoolMemberProjectResponse(BaseAPISpec): + model_config = ConfigDict( + extra="forbid", + ) + member_type: Literal["project"] = "project" + id: str = Field( + ..., description="Project id (ULID)", examples=["01ARZ3NDEKTSV4RRFFQ69G5FAW"] + ) + relation: Relation5 + namespace: str = Field( + ..., description="Full project namespace path (e.g. user/project)" + ) + name: str + + class Version(BaseAPISpec): version: str @@ -453,6 +506,43 @@ class PoolMembers( ] +class PoolMemberUserResponse(BaseAPISpec): + model_config = ConfigDict( + extra="forbid", + ) + member_type: Literal["user"] = "user" + id: str = Field( + ..., + description="Keycloak user ID", + examples=["f74a228b-1790-4276-af5f-25c2424e9b0c"], + pattern="^[A-Za-z0-9]{1}[A-Za-z0-9-]+$", + ) + relation: Relation3 + email: str + + +class PoolMembersResponse( + RootModel[ + list[ + Annotated[ + PoolMemberUserResponse + | PoolMemberGroupResponse + | PoolMemberProjectResponse, + Field(discriminator="member_type"), + ] + ] + ] +): + root: list[ + Annotated[ + PoolMemberUserResponse + | PoolMemberGroupResponse + | PoolMemberProjectResponse, + Field(discriminator="member_type"), + ] + ] + + class QuotaPatch(BaseAPISpec): model_config = ConfigDict( extra="forbid", From ecbf33111ba2a00340e24ad555e2a9d8a18fd8c4 Mon Sep 17 00:00:00 2001 From: Salim Kayal Date: Wed, 13 May 2026 09:06:42 +0200 Subject: [PATCH 16/24] feat: return PoolMemberResponse (include metadata) for /members entrypoints --- .../renku_data_services/crc/blueprints.py | 50 +++++++++++++------ components/renku_data_services/crc/db.py | 20 ++++++-- 2 files changed, 52 insertions(+), 18 deletions(-) diff --git a/components/renku_data_services/crc/blueprints.py b/components/renku_data_services/crc/blueprints.py index c429f7a69..fa7770993 100644 --- a/components/renku_data_services/crc/blueprints.py +++ b/components/renku_data_services/crc/blueprints.py @@ -3,6 +3,7 @@ import asyncio from contextlib import suppress from dataclasses import dataclass +from typing import Any from sanic import HTTPResponse, Request, empty, json from sanic_ext import validate @@ -24,7 +25,12 @@ validate_resource_pool_post, validate_resource_pool_put_or_patch, ) -from renku_data_services.crc.db import ClusterRepository, MemberRepository, ResourcePoolRepository +from renku_data_services.crc.db import ( + ClusterRepository, + MemberRepository, + ResourcePoolMemberResult, + ResourcePoolRepository, +) from renku_data_services.crc.models import MemberType, ResourcePoolMemberIdentifier from renku_data_services.users.db import UserRepo as KcUserRepo from renku_data_services.users.models import UserInfo @@ -263,8 +269,8 @@ def get_all(self) -> BlueprintFactoryResponse: async def _get_all(_: Request, user: base_models.APIUser, resource_pool_id: int) -> HTTPResponse: members = await self.repo.get_resource_pool_members(user, resource_pool_id) return validated_json( - apispec.PoolMembers, - [{"member_type": m.member_type.value, "id": m.member_id, "relation": m.relation} for m in members], + apispec.PoolMembersResponse, + [self._dump_member_response(m) for m in members], ) return "/resource_pools//members", ["GET"], _get_all @@ -301,15 +307,10 @@ async def _put_post( self, user: base_models.APIUser, resource_pool_id: int, body: apispec.PoolMembers, post: bool = True ) -> HTTPResponse: identifiers = self._to_identifiers(body) - updated = await self.repo.update_resource_pool_members( - api_user=user, - resource_pool_id=resource_pool_id, - members=identifiers, - append=post, - ) + members = await self.repo.update_resource_pool_members(user, resource_pool_id, identifiers, append=post) return validated_json( - apispec.PoolMembers, - [{"member_type": m.member_type.value, "id": m.member_id, "relation": m.relation} for m in updated], + apispec.PoolMembersResponse, + [self._dump_member_response(m) for m in members], status=201 if post else 200, ) @@ -325,14 +326,14 @@ async def _get( members = await self.repo.get_resource_pool_members(user, resource_pool_id) for m in members: if m.member_type.value == member_type and m.member_id == member_id: - payload = {"member_type": m.member_type.value, "id": m.member_id, "relation": m.relation} + payload = self._dump_member_response(m) match member_type: case "user": - return validated_json(apispec.PoolMemberUser, payload) + return validated_json(apispec.PoolMemberUserResponse, payload) case "group": - return validated_json(apispec.PoolMemberGroup, payload) + return validated_json(apispec.PoolMemberGroupResponse, payload) case "project": - return validated_json(apispec.PoolMemberProject, payload) + return validated_json(apispec.PoolMemberProjectResponse, payload) case _: raise errors.ValidationError(message=f"Invalid member type: {member_type}") raise errors.MissingResourceError( @@ -395,6 +396,25 @@ def _to_identifiers(body: apispec.PoolMembers) -> list[ResourcePoolMemberIdentif ) return identifiers + @staticmethod + def _dump_member_response(m: ResourcePoolMemberResult) -> dict[str, Any]: + """Serialize a member result for GET responses.""" + result: dict[str, Any] = { + "member_type": m.member_type.value, + "id": m.member_id, + "relation": m.relation, + } + match m.member_type: + case MemberType.USER: + result["email"] = m.email or "" + case MemberType.PROJECT: + result["namespace"] = m.namespace or "" + result["name"] = m.name or "" + case MemberType.GROUP: + result["slug"] = m.slug or "" + result["name"] = m.name or "" + return result + @dataclass(kw_only=True) class ClassesBP(CustomBlueprint): diff --git a/components/renku_data_services/crc/db.py b/components/renku_data_services/crc/db.py index a729d8e13..1a6e29edd 100644 --- a/components/renku_data_services/crc/db.py +++ b/components/renku_data_services/crc/db.py @@ -925,6 +925,10 @@ class ResourcePoolMemberResult: member_type: MemberType member_id: str relation: str + slug: str | None = None + name: str | None = None + email: str | None = None + namespace: str | None = None class MemberRepository(_Base): @@ -1376,17 +1380,25 @@ async def get_resource_pool_members( for subject_type, subject_id, relation in raw_members: match subject_type: case ResourceType.user.value: + user_info = await self.kc_user_repo.get_user(id=subject_id) results.append( - ResourcePoolMemberResult(member_type=MemberType.USER, member_id=subject_id, relation=relation) + ResourcePoolMemberResult( + member_type=MemberType.USER, + member_id=subject_id, + relation=relation, + email=user_info.email or "" if user_info else "", + ) ) case ResourceType.group.value: try: - await self.group_repo.get_group_by_id(api_user, ULID.from_str(subject_id)) + group = await self.group_repo.get_group_by_id(api_user, ULID.from_str(subject_id)) results.append( ResourcePoolMemberResult( member_type=MemberType.GROUP, member_id=subject_id, relation=relation, + slug=group.slug, + name=group.name, ) ) except (errors.MissingResourceError, ValueError): @@ -1395,12 +1407,14 @@ async def get_resource_pool_members( ) case ResourceType.project.value: try: - await self.project_repo.get_project_by_id(api_user, ULID.from_str(subject_id)) + project = await self.project_repo.get_project_by_id(api_user, ULID.from_str(subject_id)) results.append( ResourcePoolMemberResult( member_type=MemberType.PROJECT, member_id=subject_id, relation=relation, + namespace=project.path.serialize(), + name=project.name, ) ) except (errors.MissingResourceError, ValueError): From cc6e340905bc804585e049e26a118e9e8b90165c Mon Sep 17 00:00:00 2001 From: Salim Kayal Date: Wed, 13 May 2026 12:15:36 +0200 Subject: [PATCH 17/24] fix: apispec returns for put post in /members --- components/renku_data_services/crc/api.spec.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/components/renku_data_services/crc/api.spec.yaml b/components/renku_data_services/crc/api.spec.yaml index e8ef0f56a..34510db96 100644 --- a/components/renku_data_services/crc/api.spec.yaml +++ b/components/renku_data_services/crc/api.spec.yaml @@ -819,7 +819,7 @@ paths: content: "application/json": schema: - $ref: "#/components/schemas/PoolMembers" + $ref: "#/components/schemas/PoolMembersResponse" "404": description: The resource pool or a member does not exist content: @@ -851,7 +851,7 @@ paths: content: "application/json": schema: - $ref: "#/components/schemas/PoolMembers" + $ref: "#/components/schemas/PoolMembersResponse" "404": description: The resource pool or a member does not exist content: From 713ed42f69f88327b9435fac9a8760c54f0cccbb Mon Sep 17 00:00:00 2001 From: Salim Kayal Date: Mon, 18 May 2026 13:16:09 +0200 Subject: [PATCH 18/24] fix: useless check Co-authored-by: Flora Thiebaut --- components/renku_data_services/authz/authz.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/components/renku_data_services/authz/authz.py b/components/renku_data_services/authz/authz.py index eea149300..c1c5290e2 100644 --- a/components/renku_data_services/authz/authz.py +++ b/components/renku_data_services/authz/authz.py @@ -716,9 +716,7 @@ async def get_resource_pool_members( Returns a list of tuples: (subject_type, subject_id, relation). Skips public_viewer and resource_pool_platform relations. """ - if isinstance(user, InternalServiceAdmin): - pass - elif not user.is_admin: + if not user.is_admin: return [] consistency = Consistency(at_least_as_fresh=zed_token) if zed_token else Consistency(fully_consistent=True) From 695f0e7cf335f82ac189722eb2b96bed8f6c6621 Mon Sep 17 00:00:00 2001 From: Salim Kayal Date: Tue, 5 May 2026 17:33:17 +0200 Subject: [PATCH 19/24] test: add integration tests for resource pool members endpoints --- .../data_api/test_resource_pools.py | 158 ++++++++++++++++++ 1 file changed, 158 insertions(+) diff --git a/test/bases/renku_data_services/data_api/test_resource_pools.py b/test/bases/renku_data_services/data_api/test_resource_pools.py index 8b1fd452c..024a85c9b 100644 --- a/test/bases/renku_data_services/data_api/test_resource_pools.py +++ b/test/bases/renku_data_services/data_api/test_resource_pools.py @@ -1707,6 +1707,7 @@ async def test_resource_pool_visibility_toggle( @pytest.mark.asyncio @pytest.mark.xdist_group("sessions") + @pytest.mark.parametrize("total_limit,user_limit", [(1000, 200), (200, 1000), (200, 0), (0, 200)]) async def test_resource_pools_quota_with_partial_usage( sanic_client, @@ -1859,6 +1860,62 @@ async def test_resource_pools_quota_with_no_usage( # usage_hours_total should be 4 hours (200 credits total limit / 50 credits per hour) assert resource_class["usage_hours_total"] == 4.0 +async def test_resource_pool_members_add_group( + sanic_client: SanicASGITestClient, + admin_headers: dict[str, str], + valid_resource_pool_payload: dict[str, Any], + create_group, + member_1_headers, + member_1_user, + cluster: KindCluster, +) -> None: + valid_resource_pool_payload["default"] = False + valid_resource_pool_payload["public"] = False + _, res = await create_rp(valid_resource_pool_payload, sanic_client) + assert res.status_code == 201 + rp = res.json + + group = await create_group( + sanic_client, "test-pool-group", admin=True, members=[{"id": member_1_user.id, "role": "viewer"}] + ) + + # Add the group to the pool via /members + member_payload = [{"member_type": "group", "id": group["id"], "relation": "group_viewer"}] + _, res = await sanic_client.post( + f"/api/data/resource_pools/{rp['id']}/members", + headers=admin_headers, + json=member_payload, + ) + assert res.status_code == 201 + + # GET /members should return the group + _, res = await sanic_client.get( + f"/api/data/resource_pools/{rp['id']}/members", + headers=admin_headers, + ) + assert res.status_code == 200 + members = res.json + group_members = [m for m in members if m.get("member_type") == "group" and m.get("id") == group["id"]] + assert len(group_members) == 1 + + # member_1 (in the group) should now be able to access the pool + _, res = await sanic_client.get( + f"/api/data/resource_pools/{rp['id']}", + headers=member_1_headers, + ) + assert res.status_code == 200 + + # GET /users should resolve and include member_1 + _, res = await sanic_client.get( + f"/api/data/resource_pools/{rp['id']}/users", + headers=admin_headers, + ) + assert res.status_code == 200 + users = res.json + user_ids = [u["id"] for u in users] + assert member_1_user.id in user_ids + + async def test_resource_pool_members_add_group( sanic_client: SanicASGITestClient, @@ -1922,6 +1979,7 @@ async def test_resource_pool_members_add_group( @pytest.mark.asyncio @pytest.mark.xdist_group("sessions") + async def test_resource_pools_quota_exceeded( sanic_client: SanicASGITestClient, admin_headers: dict[str, str], @@ -2002,6 +2060,62 @@ async def test_resource_pools_quota_exceeded( # usage_hours_total should be 2.0 hours (50 credits/hour * 2 hours = 100 credits limit) assert resource_class["usage_hours_total"] == 2.0 +async def test_resource_pool_members_add_project( + sanic_client: SanicASGITestClient, + admin_headers: dict[str, str], + valid_resource_pool_payload: dict[str, Any], + create_project, + member_1_headers, + member_1_user, + cluster: KindCluster, +) -> None: + valid_resource_pool_payload["default"] = False + valid_resource_pool_payload["public"] = False + _, res = await create_rp(valid_resource_pool_payload, sanic_client) + assert res.status_code == 201 + rp = res.json + + project = await create_project( + sanic_client, "test-pool-project", admin=True, members=[{"id": member_1_user.id, "role": "viewer"}] + ) + + # Add the project to the pool via /members + member_payload = [{"member_type": "project", "id": project["id"], "relation": "project_viewer"}] + _, res = await sanic_client.post( + f"/api/data/resource_pools/{rp['id']}/members", + headers=admin_headers, + json=member_payload, + ) + assert res.status_code == 201 + + # GET /members should return the project + _, res = await sanic_client.get( + f"/api/data/resource_pools/{rp['id']}/members", + headers=admin_headers, + ) + assert res.status_code == 200 + members = res.json + project_members = [m for m in members if m.get("member_type") == "project" and m.get("id") == project["id"]] + assert len(project_members) == 1 + + # member_1 (in the project) should now be able to access the pool + _, res = await sanic_client.get( + f"/api/data/resource_pools/{rp['id']}", + headers=member_1_headers, + ) + assert res.status_code == 200 + + # GET /users should resolve and include member_1 + _, res = await sanic_client.get( + f"/api/data/resource_pools/{rp['id']}/users", + headers=admin_headers, + ) + assert res.status_code == 200 + users = res.json + user_ids = [u["id"] for u in users] + assert member_1_user.id in user_ids + + async def test_resource_pool_members_add_project( sanic_client: SanicASGITestClient, @@ -2065,6 +2179,7 @@ async def test_resource_pool_members_add_project( @pytest.mark.asyncio @pytest.mark.xdist_group("sessions") + async def test_resource_pools_quota_with_no_limits( sanic_client: SanicASGITestClient, admin_headers: dict[str, str], @@ -2137,6 +2252,48 @@ async def test_resource_pools_quota_with_no_limits( # usage_hours_total should not exist in the response since it's None assert "usage_hours_total" not in resource_class +async def test_resource_pool_members_put_replaces( + sanic_client: SanicASGITestClient, + admin_headers: dict[str, str], + valid_resource_pool_payload: dict[str, Any], + create_group, + cluster: KindCluster, +) -> None: + valid_resource_pool_payload["default"] = False + valid_resource_pool_payload["public"] = False + _, res = await create_rp(valid_resource_pool_payload, sanic_client) + assert res.status_code == 201 + rp = res.json + + group1 = await create_group(sanic_client, "test-group-1", admin=True) + group2 = await create_group(sanic_client, "test-group-2", admin=True) + + # Add group1 + _, res = await sanic_client.post( + f"/api/data/resource_pools/{rp['id']}/members", + headers=admin_headers, + json=[{"member_type": "group", "id": group1["id"], "relation": "group_viewer"}], + ) + assert res.status_code == 201 + + # PUT with only group2 should replace + _, res = await sanic_client.put( + f"/api/data/resource_pools/{rp['id']}/members", + headers=admin_headers, + json=[{"member_type": "group", "id": group2["id"], "relation": "group_viewer"}], + ) + assert res.status_code == 200 + + _, res = await sanic_client.get( + f"/api/data/resource_pools/{rp['id']}/members", + headers=admin_headers, + ) + assert res.status_code == 200 + members = res.json + assert len(members) == 1 + assert members[0]["id"] == group2["id"] + + async def test_resource_pool_members_put_replaces( sanic_client: SanicASGITestClient, @@ -2186,6 +2343,7 @@ async def test_resource_pool_members_put_replaces( @pytest.mark.asyncio @pytest.mark.xdist_group("sessions") + async def test_resource_pools_quota_with_no_costs( sanic_client: SanicASGITestClient, admin_headers: dict[str, str], From e66576361f54120e7b5843906fb046b7fbe74650 Mon Sep 17 00:00:00 2001 From: Salim Kayal Date: Tue, 5 May 2026 22:29:06 +0200 Subject: [PATCH 20/24] squashme: style --- .../renku_data_services/data_api/test_resource_pools.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/test/bases/renku_data_services/data_api/test_resource_pools.py b/test/bases/renku_data_services/data_api/test_resource_pools.py index 024a85c9b..8db26fe7f 100644 --- a/test/bases/renku_data_services/data_api/test_resource_pools.py +++ b/test/bases/renku_data_services/data_api/test_resource_pools.py @@ -1707,7 +1707,6 @@ async def test_resource_pool_visibility_toggle( @pytest.mark.asyncio @pytest.mark.xdist_group("sessions") - @pytest.mark.parametrize("total_limit,user_limit", [(1000, 200), (200, 1000), (200, 0), (0, 200)]) async def test_resource_pools_quota_with_partial_usage( sanic_client, @@ -1860,6 +1859,7 @@ async def test_resource_pools_quota_with_no_usage( # usage_hours_total should be 4 hours (200 credits total limit / 50 credits per hour) assert resource_class["usage_hours_total"] == 4.0 + async def test_resource_pool_members_add_group( sanic_client: SanicASGITestClient, admin_headers: dict[str, str], @@ -1979,7 +1979,6 @@ async def test_resource_pool_members_add_group( @pytest.mark.asyncio @pytest.mark.xdist_group("sessions") - async def test_resource_pools_quota_exceeded( sanic_client: SanicASGITestClient, admin_headers: dict[str, str], @@ -2060,6 +2059,7 @@ async def test_resource_pools_quota_exceeded( # usage_hours_total should be 2.0 hours (50 credits/hour * 2 hours = 100 credits limit) assert resource_class["usage_hours_total"] == 2.0 + async def test_resource_pool_members_add_project( sanic_client: SanicASGITestClient, admin_headers: dict[str, str], @@ -2179,7 +2179,6 @@ async def test_resource_pool_members_add_project( @pytest.mark.asyncio @pytest.mark.xdist_group("sessions") - async def test_resource_pools_quota_with_no_limits( sanic_client: SanicASGITestClient, admin_headers: dict[str, str], @@ -2252,6 +2251,7 @@ async def test_resource_pools_quota_with_no_limits( # usage_hours_total should not exist in the response since it's None assert "usage_hours_total" not in resource_class + async def test_resource_pool_members_put_replaces( sanic_client: SanicASGITestClient, admin_headers: dict[str, str], @@ -2343,7 +2343,6 @@ async def test_resource_pool_members_put_replaces( @pytest.mark.asyncio @pytest.mark.xdist_group("sessions") - async def test_resource_pools_quota_with_no_costs( sanic_client: SanicASGITestClient, admin_headers: dict[str, str], From ed41def4bd566927a23e048953ca92efeb420415 Mon Sep 17 00:00:00 2001 From: Salim Kayal Date: Mon, 11 May 2026 11:33:43 +0200 Subject: [PATCH 21/24] fix: replace wildcard before-validator with model_validator to avoid discriminator conflict --- components/renku_data_services/crc/apispec_base.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/components/renku_data_services/crc/apispec_base.py b/components/renku_data_services/crc/apispec_base.py index c03e18838..57d16bdb7 100644 --- a/components/renku_data_services/crc/apispec_base.py +++ b/components/renku_data_services/crc/apispec_base.py @@ -2,7 +2,7 @@ from pathlib import PurePosixPath -from pydantic import BaseModel, ConfigDict, field_validator +from pydantic import BaseModel, ConfigDict, field_validator, model_validator from ulid import ULID from renku_data_services.session import models From 58c901dc284d3474abeda926122160308126115d Mon Sep 17 00:00:00 2001 From: Salim Kayal Date: Mon, 11 May 2026 11:49:05 +0200 Subject: [PATCH 22/24] fix: Specify ULID field conversion --- components/renku_data_services/crc/apispec_base.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/components/renku_data_services/crc/apispec_base.py b/components/renku_data_services/crc/apispec_base.py index 57d16bdb7..c03e18838 100644 --- a/components/renku_data_services/crc/apispec_base.py +++ b/components/renku_data_services/crc/apispec_base.py @@ -2,7 +2,7 @@ from pathlib import PurePosixPath -from pydantic import BaseModel, ConfigDict, field_validator, model_validator +from pydantic import BaseModel, ConfigDict, field_validator from ulid import ULID from renku_data_services.session import models From 6474aabc4058b0c839f097172d31b2a776fe071f Mon Sep 17 00:00:00 2001 From: Salim Kayal Date: Wed, 13 May 2026 09:04:01 +0200 Subject: [PATCH 23/24] feat: validate new output fields for PoolMemberResponse --- .../data_api/test_resource_pools.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/test/bases/renku_data_services/data_api/test_resource_pools.py b/test/bases/renku_data_services/data_api/test_resource_pools.py index 8db26fe7f..0691b3ee8 100644 --- a/test/bases/renku_data_services/data_api/test_resource_pools.py +++ b/test/bases/renku_data_services/data_api/test_resource_pools.py @@ -1887,6 +1887,8 @@ async def test_resource_pool_members_add_group( json=member_payload, ) assert res.status_code == 201 + assert res.json[0]["slug"] == "test-pool-group" + assert res.json[0]["name"] == "test-pool-group" # GET /members should return the group _, res = await sanic_client.get( @@ -1897,6 +1899,8 @@ async def test_resource_pool_members_add_group( members = res.json group_members = [m for m in members if m.get("member_type") == "group" and m.get("id") == group["id"]] assert len(group_members) == 1 + assert group_members[0]["slug"] == "test-pool-group" + assert group_members[0]["name"] == "test-pool-group" # member_1 (in the group) should now be able to access the pool _, res = await sanic_client.get( @@ -2087,6 +2091,8 @@ async def test_resource_pool_members_add_project( json=member_payload, ) assert res.status_code == 201 + assert res.json[0]["namespace"] == "admin.doe/test-pool-project" + assert res.json[0]["name"] == "test-pool-project" # GET /members should return the project _, res = await sanic_client.get( @@ -2097,6 +2103,8 @@ async def test_resource_pool_members_add_project( members = res.json project_members = [m for m in members if m.get("member_type") == "project" and m.get("id") == project["id"]] assert len(project_members) == 1 + assert project_members[0]["namespace"] == "admin.doe/test-pool-project" + assert project_members[0]["name"] == "test-pool-project" # member_1 (in the project) should now be able to access the pool _, res = await sanic_client.get( @@ -2283,6 +2291,8 @@ async def test_resource_pool_members_put_replaces( json=[{"member_type": "group", "id": group2["id"], "relation": "group_viewer"}], ) assert res.status_code == 200 + assert res.json[0]["slug"] == "test-group-2" + assert res.json[0]["name"] == "test-group-2" _, res = await sanic_client.get( f"/api/data/resource_pools/{rp['id']}/members", @@ -2292,6 +2302,8 @@ async def test_resource_pool_members_put_replaces( members = res.json assert len(members) == 1 assert members[0]["id"] == group2["id"] + assert members[0]["slug"] == "test-group-2" + assert members[0]["name"] == "test-group-2" From ae0509b790d38863429f6b05718610540ab7bc6a Mon Sep 17 00:00:00 2001 From: Salim Kayal Date: Tue, 19 May 2026 16:02:07 +0200 Subject: [PATCH 24/24] squashme: formatting --- .../data_api/test_resource_pools.py | 169 ------------------ 1 file changed, 169 deletions(-) diff --git a/test/bases/renku_data_services/data_api/test_resource_pools.py b/test/bases/renku_data_services/data_api/test_resource_pools.py index 0691b3ee8..8b1fd452c 100644 --- a/test/bases/renku_data_services/data_api/test_resource_pools.py +++ b/test/bases/renku_data_services/data_api/test_resource_pools.py @@ -1860,67 +1860,6 @@ async def test_resource_pools_quota_with_no_usage( assert resource_class["usage_hours_total"] == 4.0 -async def test_resource_pool_members_add_group( - sanic_client: SanicASGITestClient, - admin_headers: dict[str, str], - valid_resource_pool_payload: dict[str, Any], - create_group, - member_1_headers, - member_1_user, - cluster: KindCluster, -) -> None: - valid_resource_pool_payload["default"] = False - valid_resource_pool_payload["public"] = False - _, res = await create_rp(valid_resource_pool_payload, sanic_client) - assert res.status_code == 201 - rp = res.json - - group = await create_group( - sanic_client, "test-pool-group", admin=True, members=[{"id": member_1_user.id, "role": "viewer"}] - ) - - # Add the group to the pool via /members - member_payload = [{"member_type": "group", "id": group["id"], "relation": "group_viewer"}] - _, res = await sanic_client.post( - f"/api/data/resource_pools/{rp['id']}/members", - headers=admin_headers, - json=member_payload, - ) - assert res.status_code == 201 - assert res.json[0]["slug"] == "test-pool-group" - assert res.json[0]["name"] == "test-pool-group" - - # GET /members should return the group - _, res = await sanic_client.get( - f"/api/data/resource_pools/{rp['id']}/members", - headers=admin_headers, - ) - assert res.status_code == 200 - members = res.json - group_members = [m for m in members if m.get("member_type") == "group" and m.get("id") == group["id"]] - assert len(group_members) == 1 - assert group_members[0]["slug"] == "test-pool-group" - assert group_members[0]["name"] == "test-pool-group" - - # member_1 (in the group) should now be able to access the pool - _, res = await sanic_client.get( - f"/api/data/resource_pools/{rp['id']}", - headers=member_1_headers, - ) - assert res.status_code == 200 - - # GET /users should resolve and include member_1 - _, res = await sanic_client.get( - f"/api/data/resource_pools/{rp['id']}/users", - headers=admin_headers, - ) - assert res.status_code == 200 - users = res.json - user_ids = [u["id"] for u in users] - assert member_1_user.id in user_ids - - - async def test_resource_pool_members_add_group( sanic_client: SanicASGITestClient, admin_headers: dict[str, str], @@ -2064,67 +2003,6 @@ async def test_resource_pools_quota_exceeded( assert resource_class["usage_hours_total"] == 2.0 -async def test_resource_pool_members_add_project( - sanic_client: SanicASGITestClient, - admin_headers: dict[str, str], - valid_resource_pool_payload: dict[str, Any], - create_project, - member_1_headers, - member_1_user, - cluster: KindCluster, -) -> None: - valid_resource_pool_payload["default"] = False - valid_resource_pool_payload["public"] = False - _, res = await create_rp(valid_resource_pool_payload, sanic_client) - assert res.status_code == 201 - rp = res.json - - project = await create_project( - sanic_client, "test-pool-project", admin=True, members=[{"id": member_1_user.id, "role": "viewer"}] - ) - - # Add the project to the pool via /members - member_payload = [{"member_type": "project", "id": project["id"], "relation": "project_viewer"}] - _, res = await sanic_client.post( - f"/api/data/resource_pools/{rp['id']}/members", - headers=admin_headers, - json=member_payload, - ) - assert res.status_code == 201 - assert res.json[0]["namespace"] == "admin.doe/test-pool-project" - assert res.json[0]["name"] == "test-pool-project" - - # GET /members should return the project - _, res = await sanic_client.get( - f"/api/data/resource_pools/{rp['id']}/members", - headers=admin_headers, - ) - assert res.status_code == 200 - members = res.json - project_members = [m for m in members if m.get("member_type") == "project" and m.get("id") == project["id"]] - assert len(project_members) == 1 - assert project_members[0]["namespace"] == "admin.doe/test-pool-project" - assert project_members[0]["name"] == "test-pool-project" - - # member_1 (in the project) should now be able to access the pool - _, res = await sanic_client.get( - f"/api/data/resource_pools/{rp['id']}", - headers=member_1_headers, - ) - assert res.status_code == 200 - - # GET /users should resolve and include member_1 - _, res = await sanic_client.get( - f"/api/data/resource_pools/{rp['id']}/users", - headers=admin_headers, - ) - assert res.status_code == 200 - users = res.json - user_ids = [u["id"] for u in users] - assert member_1_user.id in user_ids - - - async def test_resource_pool_members_add_project( sanic_client: SanicASGITestClient, admin_headers: dict[str, str], @@ -2260,53 +2138,6 @@ async def test_resource_pools_quota_with_no_limits( assert "usage_hours_total" not in resource_class -async def test_resource_pool_members_put_replaces( - sanic_client: SanicASGITestClient, - admin_headers: dict[str, str], - valid_resource_pool_payload: dict[str, Any], - create_group, - cluster: KindCluster, -) -> None: - valid_resource_pool_payload["default"] = False - valid_resource_pool_payload["public"] = False - _, res = await create_rp(valid_resource_pool_payload, sanic_client) - assert res.status_code == 201 - rp = res.json - - group1 = await create_group(sanic_client, "test-group-1", admin=True) - group2 = await create_group(sanic_client, "test-group-2", admin=True) - - # Add group1 - _, res = await sanic_client.post( - f"/api/data/resource_pools/{rp['id']}/members", - headers=admin_headers, - json=[{"member_type": "group", "id": group1["id"], "relation": "group_viewer"}], - ) - assert res.status_code == 201 - - # PUT with only group2 should replace - _, res = await sanic_client.put( - f"/api/data/resource_pools/{rp['id']}/members", - headers=admin_headers, - json=[{"member_type": "group", "id": group2["id"], "relation": "group_viewer"}], - ) - assert res.status_code == 200 - assert res.json[0]["slug"] == "test-group-2" - assert res.json[0]["name"] == "test-group-2" - - _, res = await sanic_client.get( - f"/api/data/resource_pools/{rp['id']}/members", - headers=admin_headers, - ) - assert res.status_code == 200 - members = res.json - assert len(members) == 1 - assert members[0]["id"] == group2["id"] - assert members[0]["slug"] == "test-group-2" - assert members[0]["name"] == "test-group-2" - - - async def test_resource_pool_members_put_replaces( sanic_client: SanicASGITestClient, admin_headers: dict[str, str],