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/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 _: diff --git a/components/renku_data_services/crc/api.spec.yaml b/components/renku_data_services/crc/api.spec.yaml index cd91f90df..3ffe3144e 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 @@ -1542,6 +1695,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 f3c4ee3cc..a928e8e2b 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-06T19:50:28+00:00 +# timestamp: 2026-05-08T14:28:33+00:00 from __future__ import annotations from enum import Enum -from typing import List, Optional, Union +from typing import List, Literal, Optional, Union from pydantic import ConfigDict, Field, RootModel from renku_data_services.crc.apispec_base import BaseAPISpec +class MemberType(Enum): + user = "user" + + +class Relation(Enum): + viewer = "viewer" + prohibited = "prohibited" + + +class MemberType1(Enum): + group = "group" + + +class Relation1(Enum): + 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(Enum): + project = "project" + + +class Relation2(Enum): + 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 @@ -375,6 +422,28 @@ 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[Union[PoolMemberUser, PoolMemberGroup, PoolMemberProject]]] +): + root: List[Union[PoolMemberUser, PoolMemberGroup, PoolMemberProject]] = Field( + ..., discriminator="member_type" + ) + + class QuotaPatch(BaseAPISpec): model_config = ConfigDict( extra="forbid", diff --git a/components/renku_data_services/crc/blueprints.py b/components/renku_data_services/crc/blueprints.py index a14f3e2e1..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 @@ -24,6 +25,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 +247,155 @@ 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), + ) + with suppress(errors.MissingResourceError): + await self.repo.revoke_resource_pool_members( + api_user=user, + resource_pool_id=resource_pool_id, + members=[identifier], + ) + 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.""" diff --git a/components/renku_data_services/crc/db.py b/components/renku_data_services/crc/db.py index 850c39343..389f5e84d 100644 --- a/components/renku_data_services/crc/db.py +++ b/components/renku_data_services/crc/db.py @@ -922,6 +922,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.""" @@ -947,47 +956,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, @@ -1171,17 +1197,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( @@ -1202,30 +1256,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 @@ -1249,7 +1303,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) @@ -1265,7 +1319,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, ) @@ -1281,7 +1338,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, ) @@ -1308,6 +1368,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.""" 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: 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..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 @@ -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}) @@ -1855,6 +1860,62 @@ 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 + + # 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( @@ -1938,6 +1999,62 @@ 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 + + # 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( @@ -2013,6 +2130,48 @@ 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 + + _, 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( @@ -2090,3 +2249,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