diff --git a/api/specs/web-server/_groups.py b/api/specs/web-server/_groups.py index 9fa015bd7b5..530460c6d8c 100644 --- a/api/specs/web-server/_groups.py +++ b/api/specs/web-server/_groups.py @@ -11,17 +11,17 @@ GroupCreate, GroupGet, GroupUpdate, + GroupUserAdd, GroupUserGet, + GroupUserUpdate, MyGroupsGet, ) from models_library.generics import Envelope from simcore_service_webserver._meta import API_VTAG -from simcore_service_webserver.groups._handlers import ( - GroupUserAdd, - GroupUserUpdate, - _ClassifiersQuery, - _GroupPathParams, - _GroupUserPathParams, +from simcore_service_webserver.groups._common.schemas import ( + GroupsClassifiersQuery, + GroupsPathParams, + GroupsUsersPathParams, ) from simcore_service_webserver.scicrunch.models import ResearchResource, ResourceHit @@ -58,7 +58,7 @@ async def create_group(_body: GroupCreate): "/groups/{gid}", response_model=Envelope[GroupGet], ) -async def get_group(_path: Annotated[_GroupPathParams, Depends()]): +async def get_group(_path: Annotated[GroupsPathParams, Depends()]): """ Get an organization group """ @@ -69,7 +69,7 @@ async def get_group(_path: Annotated[_GroupPathParams, Depends()]): response_model=Envelope[GroupGet], ) async def update_group( - _path: Annotated[_GroupPathParams, Depends()], + _path: Annotated[GroupsPathParams, Depends()], _body: GroupUpdate, ): """ @@ -81,7 +81,7 @@ async def update_group( "/groups/{gid}", status_code=status.HTTP_204_NO_CONTENT, ) -async def delete_group(_path: Annotated[_GroupPathParams, Depends()]): +async def delete_group(_path: Annotated[GroupsPathParams, Depends()]): """ Deletes organization groups """ @@ -91,7 +91,7 @@ async def delete_group(_path: Annotated[_GroupPathParams, Depends()]): "/groups/{gid}/users", response_model=Envelope[list[GroupUserGet]], ) -async def get_all_group_users(_path: Annotated[_GroupPathParams, Depends()]): +async def get_all_group_users(_path: Annotated[GroupsPathParams, Depends()]): """ Gets users in organization groups """ @@ -102,11 +102,11 @@ async def get_all_group_users(_path: Annotated[_GroupPathParams, Depends()]): status_code=status.HTTP_204_NO_CONTENT, ) async def add_group_user( - _path: Annotated[_GroupPathParams, Depends()], + _path: Annotated[GroupsPathParams, Depends()], _body: GroupUserAdd, ): """ - Adds a user to an organization group + Adds a user to an organization group using their username, user ID, or email (subject to privacy settings) """ @@ -115,7 +115,7 @@ async def add_group_user( response_model=Envelope[GroupUserGet], ) async def get_group_user( - _path: Annotated[_GroupUserPathParams, Depends()], + _path: Annotated[GroupsUsersPathParams, Depends()], ): """ Gets specific user in an organization group @@ -127,7 +127,7 @@ async def get_group_user( response_model=Envelope[GroupUserGet], ) async def update_group_user( - _path: Annotated[_GroupUserPathParams, Depends()], + _path: Annotated[GroupsUsersPathParams, Depends()], _body: GroupUserUpdate, ): """ @@ -140,7 +140,7 @@ async def update_group_user( status_code=status.HTTP_204_NO_CONTENT, ) async def delete_group_user( - _path: Annotated[_GroupUserPathParams, Depends()], + _path: Annotated[GroupsUsersPathParams, Depends()], ): """ Removes a user from an organization group @@ -157,8 +157,8 @@ async def delete_group_user( response_model=Envelope[dict[str, Any]], ) async def get_group_classifiers( - _path: Annotated[_GroupPathParams, Depends()], - _query: Annotated[_ClassifiersQuery, Depends()], + _path: Annotated[GroupsPathParams, Depends()], + _query: Annotated[GroupsClassifiersQuery, Depends()], ): ... diff --git a/api/specs/web-server/_users.py b/api/specs/web-server/_users.py index c161a7aa69a..cb1904f3bb7 100644 --- a/api/specs/web-server/_users.py +++ b/api/specs/web-server/_users.py @@ -7,7 +7,7 @@ from typing import Annotated from fastapi import APIRouter, Depends, status -from models_library.api_schemas_webserver.users import ProfileGet, ProfileUpdate +from models_library.api_schemas_webserver.users import MyProfileGet, MyProfilePatch from models_library.api_schemas_webserver.users_preferences import PatchRequestBody from models_library.generics import Envelope from models_library.user_preferences import PreferenceIdentifier @@ -34,7 +34,7 @@ @router.get( "/me", - response_model=Envelope[ProfileGet], + response_model=Envelope[MyProfileGet], ) async def get_my_profile(): ... @@ -44,7 +44,7 @@ async def get_my_profile(): "/me", status_code=status.HTTP_204_NO_CONTENT, ) -async def update_my_profile(_profile: ProfileUpdate): +async def update_my_profile(_profile: MyProfilePatch): ... @@ -54,7 +54,7 @@ async def update_my_profile(_profile: ProfileUpdate): deprecated=True, description="Use PATCH instead", ) -async def replace_my_profile(_profile: ProfileUpdate): +async def replace_my_profile(_profile: MyProfilePatch): ... diff --git a/packages/models-library/src/models_library/api_schemas_webserver/_base.py b/packages/models-library/src/models_library/api_schemas_webserver/_base.py index 718984116c7..948c4c9b3ea 100644 --- a/packages/models-library/src/models_library/api_schemas_webserver/_base.py +++ b/packages/models-library/src/models_library/api_schemas_webserver/_base.py @@ -24,7 +24,8 @@ class InputSchemaWithoutCamelCase(BaseModel): class InputSchema(BaseModel): model_config = ConfigDict( - **InputSchemaWithoutCamelCase.model_config, alias_generator=snake_to_camel + **InputSchemaWithoutCamelCase.model_config, + alias_generator=snake_to_camel, ) @@ -50,7 +51,7 @@ def data( exclude_unset=exclude_unset, exclude_defaults=exclude_defaults, exclude_none=exclude_none, - **kwargs + **kwargs, ) def data_json( @@ -67,5 +68,5 @@ def data_json( exclude_unset=exclude_unset, exclude_defaults=exclude_defaults, exclude_none=exclude_none, - **kwargs + **kwargs, ) diff --git a/packages/models-library/src/models_library/api_schemas_webserver/groups.py b/packages/models-library/src/models_library/api_schemas_webserver/groups.py index 71bbc5ae068..d595447c3d3 100644 --- a/packages/models-library/src/models_library/api_schemas_webserver/groups.py +++ b/packages/models-library/src/models_library/api_schemas_webserver/groups.py @@ -1,5 +1,7 @@ from contextlib import suppress +from typing import Annotated, Any, Self, TypeVar +from common_library.basic_types import DEFAULT_FACTORY from pydantic import ( AnyHttpUrl, AnyUrl, @@ -12,11 +14,25 @@ model_validator, ) +from ..basic_types import IDStr from ..emails import LowerCaseEmailStr +from ..groups import ( + AccessRightsDict, + Group, + GroupMember, + StandardGroupCreate, + StandardGroupUpdate, +) from ..users import UserID from ..utils.common_validators import create__check_only_one_is_set__root_validator from ._base import InputSchema, OutputSchema +S = TypeVar("S", bound=BaseModel) + + +def _rename_keys(source: dict, name_map: dict[str, str]) -> dict[str, Any]: + return {name_map.get(k, k): v for k, v in source.items()} + class GroupAccessRights(BaseModel): """ @@ -26,6 +42,7 @@ class GroupAccessRights(BaseModel): read: bool write: bool delete: bool + model_config = ConfigDict( json_schema_extra={ "examples": [ @@ -45,11 +62,40 @@ class GroupGet(OutputSchema): default=None, description="url to the group thumbnail" ) access_rights: GroupAccessRights = Field(..., alias="accessRights") - inclusion_rules: dict[str, str] = Field( - default_factory=dict, - description="Maps user's column and regular expression", - alias="inclusionRules", - ) + + inclusion_rules: Annotated[ + dict[str, str], + Field( + default_factory=dict, + alias="inclusionRules", + deprecated=True, + ), + ] = DEFAULT_FACTORY + + @classmethod + def from_model(cls, group: Group, access_rights: AccessRightsDict) -> Self: + # Merges both service models into this schema + return cls.model_validate( + { + **_rename_keys( + group.model_dump( + include={ + "gid", + "name", + "description", + "thumbnail", + }, + exclude={"access_rights", "inclusion_rules"}, + exclude_unset=True, + by_alias=False, + ), + name_map={ + "name": "label", + }, + ), + "access_rights": access_rights, + } + ) model_config = ConfigDict( json_schema_extra={ @@ -78,7 +124,6 @@ class GroupGet(OutputSchema): "label": "SPARCi", "description": "Stimulating Peripheral Activity to Relieve Conditions", "thumbnail": "https://placekitten.com/15/15", - "inclusionRules": {"email": r"@(sparc)+\.(io|com|us)$"}, "accessRights": {"read": True, "write": True, "delete": True}, }, ] @@ -100,12 +145,36 @@ class GroupCreate(InputSchema): description: str thumbnail: AnyUrl | None = None + def to_model(self) -> StandardGroupCreate: + data = _rename_keys( + self.model_dump( + mode="json", + # NOTE: intentionally inclusion_rules are not exposed to the REST api + include={"label", "description", "thumbnail"}, + exclude_unset=True, + ), + name_map={"label": "name"}, + ) + return StandardGroupCreate(**data) + class GroupUpdate(InputSchema): label: str | None = None description: str | None = None thumbnail: AnyUrl | None = None + def to_model(self) -> StandardGroupUpdate: + data = _rename_keys( + self.model_dump( + mode="json", + # NOTE: intentionally inclusion_rules are not exposed to the REST api + include={"label", "description", "thumbnail"}, + exclude_unset=True, + ), + name_map={"label": "name"}, + ) + return StandardGroupUpdate(**data) + class MyGroupsGet(OutputSchema): me: GroupGet @@ -156,20 +225,42 @@ class MyGroupsGet(OutputSchema): class GroupUserGet(BaseModel): - id: str | None = Field(None, description="the user id", coerce_numbers_to_str=True) - login: LowerCaseEmailStr | None = Field(None, description="the user login email") - first_name: str | None = Field(None, description="the user first name") - last_name: str | None = Field(None, description="the user last name") - gravatar_id: str | None = Field(None, description="the user gravatar id hash") - gid: str | None = Field( - None, description="the user primary gid", coerce_numbers_to_str=True - ) + # OutputSchema + + # Identifiers + id: Annotated[ + str | None, Field(description="the user id", coerce_numbers_to_str=True) + ] = None + user_name: Annotated[IDStr, Field(alias="userName")] + gid: Annotated[ + str | None, + Field(description="the user primary gid", coerce_numbers_to_str=True), + ] = None + + # Private Profile + login: Annotated[ + LowerCaseEmailStr | None, + Field(description="the user's email, if privacy settings allows"), + ] = None + first_name: Annotated[ + str | None, Field(description="If privacy settings allows") + ] = None + last_name: Annotated[ + str | None, Field(description="If privacy settings allows") + ] = None + gravatar_id: Annotated[ + str | None, Field(description="the user gravatar id hash", deprecated=True) + ] = None + + # Access Rights access_rights: GroupAccessRights = Field(..., alias="accessRights") model_config = ConfigDict( + populate_by_name=True, json_schema_extra={ "example": { "id": "1", + "userName": "mrmith", "login": "mr.smith@matrix.com", "first_name": "Mr", "last_name": "Smith", @@ -181,9 +272,23 @@ class GroupUserGet(BaseModel): "delete": False, }, } - } + }, ) + @classmethod + def from_model(cls, user: GroupMember) -> Self: + return cls.model_validate( + { + "id": user.id, + "user_name": user.name, + "login": user.email, + "first_name": user.first_name, + "last_name": user.last_name, + "gid": user.primary_gid, + "access_rights": user.access_rights, + } + ) + class GroupUserAdd(InputSchema): """ @@ -191,14 +296,25 @@ class GroupUserAdd(InputSchema): """ uid: UserID | None = None - email: LowerCaseEmailStr | None = None + user_name: Annotated[IDStr | None, Field(alias="userName")] = None + email: Annotated[ + LowerCaseEmailStr | None, + Field( + description="Accessible only if the user has opted to share their email in privacy settings" + ), + ] = None _check_uid_or_email = model_validator(mode="after")( - create__check_only_one_is_set__root_validator(["uid", "email"]) + create__check_only_one_is_set__root_validator(["uid", "email", "user_name"]) ) model_config = ConfigDict( - json_schema_extra={"examples": [{"uid": 42}, {"email": "foo@email.com"}]} + json_schema_extra={ + "examples": [ + {"uid": 42}, + {"email": "foo@email.com"}, + ] + } ) diff --git a/packages/models-library/src/models_library/api_schemas_webserver/users.py b/packages/models-library/src/models_library/api_schemas_webserver/users.py index ae7b9f89504..f0dd3d8bcfb 100644 --- a/packages/models-library/src/models_library/api_schemas_webserver/users.py +++ b/packages/models-library/src/models_library/api_schemas_webserver/users.py @@ -13,17 +13,17 @@ from ._base import InputSchema, OutputSchema -class ProfilePrivacyGet(OutputSchema): +class MyProfilePrivacyGet(OutputSchema): hide_fullname: bool hide_email: bool -class ProfilePrivacyUpdate(InputSchema): +class MyProfilePrivacyPatch(InputSchema): hide_fullname: bool | None = None hide_email: bool | None = None -class ProfileGet(BaseModel): +class MyProfileGet(BaseModel): # WARNING: do not use InputSchema until front-end is updated! id: UserID user_name: Annotated[ @@ -45,7 +45,7 @@ class ProfileGet(BaseModel): ), ] = None - privacy: ProfilePrivacyGet + privacy: MyProfilePrivacyGet preferences: AggregatedPreferences model_config = ConfigDict( @@ -77,13 +77,13 @@ def _to_upper_string(cls, v): return v -class ProfileUpdate(BaseModel): +class MyProfilePatch(BaseModel): # WARNING: do not use InputSchema until front-end is updated! first_name: FirstNameStr | None = None last_name: LastNameStr | None = None user_name: Annotated[IDStr | None, Field(alias="userName")] = None - privacy: ProfilePrivacyUpdate | None = None + privacy: MyProfilePrivacyPatch | None = None model_config = ConfigDict( json_schema_extra={ diff --git a/packages/models-library/src/models_library/groups.py b/packages/models-library/src/models_library/groups.py index 488776b6d8e..e79928574a6 100644 --- a/packages/models-library/src/models_library/groups.py +++ b/packages/models-library/src/models_library/groups.py @@ -1,9 +1,13 @@ import enum -from typing import Final +from typing import Annotated, Final, NamedTuple, TypeAlias -from pydantic import BaseModel, ConfigDict, Field, field_validator +from common_library.basic_types import DEFAULT_FACTORY +from pydantic import BaseModel, ConfigDict, EmailStr, Field, field_validator from pydantic.types import PositiveInt +from typing_extensions import TypedDict +from .basic_types import IDStr +from .users import GroupID, UserID from .utils.common_validators import create_enums_pre_validator EVERYONE_GROUP_ID: Final[int] = 1 @@ -25,15 +29,77 @@ class Group(BaseModel): gid: PositiveInt name: str description: str - group_type: GroupTypeInModel = Field(..., alias="type") + group_type: Annotated[GroupTypeInModel, Field(alias="type")] thumbnail: str | None + inclusion_rules: Annotated[ + dict[str, str], + Field( + default_factory=dict, + ), + ] = DEFAULT_FACTORY + _from_equivalent_enums = field_validator("group_type", mode="before")( create_enums_pre_validator(GroupTypeInModel) ) + model_config = ConfigDict(populate_by_name=True) + + +class AccessRightsDict(TypedDict): + read: bool + write: bool + delete: bool + + +GroupInfoTuple: TypeAlias = tuple[Group, AccessRightsDict] + + +class GroupsByTypeTuple(NamedTuple): + primary: GroupInfoTuple | None + standard: list[GroupInfoTuple] + everyone: GroupInfoTuple | None + + +class GroupMember(BaseModel): + # identifiers + id: UserID + name: IDStr + primary_gid: GroupID + + # private profile + email: EmailStr | None + first_name: str | None + last_name: str | None + + # group access + access_rights: AccessRightsDict + + model_config = ConfigDict(from_attributes=True) + + +class StandardGroupCreate(BaseModel): + name: str + description: str | None = None + thumbnail: str | None = None + inclusion_rules: Annotated[ + dict[str, str], + Field( + default_factory=dict, + description="Maps user's column and regular expression", + ), + ] = DEFAULT_FACTORY + + +class StandardGroupUpdate(BaseModel): + name: str | None = None + description: str | None = None + thumbnail: str | None = None + inclusion_rules: dict[str, str] | None = None + class GroupAtDB(Group): + # NOTE: deprecate and use `Group` instead model_config = ConfigDict( from_attributes=True, json_schema_extra={ diff --git a/packages/models-library/src/models_library/utils/common_validators.py b/packages/models-library/src/models_library/utils/common_validators.py index 23cb62739db..d008f87e8cf 100644 --- a/packages/models-library/src/models_library/utils/common_validators.py +++ b/packages/models-library/src/models_library/utils/common_validators.py @@ -87,7 +87,9 @@ def null_or_none_str_to_none_validator(value: Any): return value -def create__check_only_one_is_set__root_validator(alternative_field_names: list[str]): +def create__check_only_one_is_set__root_validator( + mutually_exclusive_field_names: list[str], +): """Ensure exactly one and only one of the alternatives is set NOTE: a field is considered here `unset` when it is `not None`. When None @@ -104,17 +106,16 @@ def create__check_only_one_is_set__root_validator(alternative_field_names: list[ """ def _validator(cls: type[BaseModel], values): - assert set(alternative_field_names).issubset(cls.model_fields) # nosec - + assert set(mutually_exclusive_field_names).issubset( # nosec + cls.model_fields + ), f"Invalid {mutually_exclusive_field_names=} passed in the factory arguments" got = { field_name: getattr(values, field_name) - for field_name in alternative_field_names + for field_name in mutually_exclusive_field_names } if not functools.reduce(operator.xor, (v is not None for v in got.values())): - msg = ( - f"Either { 'or'.join(got.keys()) } must be set, but not both. Got {got}" - ) + msg = f"Either { ' or '.join(got.keys()) } must be set, but not both. Got {got}" raise ValueError(msg) return values diff --git a/packages/postgres-database/src/simcore_postgres_database/migration/versions/5e27063c3ac9_set_privacy_hide_email_to_false_.py b/packages/postgres-database/src/simcore_postgres_database/migration/versions/5e27063c3ac9_set_privacy_hide_email_to_false_.py new file mode 100644 index 00000000000..2381193baeb --- /dev/null +++ b/packages/postgres-database/src/simcore_postgres_database/migration/versions/5e27063c3ac9_set_privacy_hide_email_to_false_.py @@ -0,0 +1,34 @@ +"""set privacy_hide_email to false temporarily + +Revision ID: 5e27063c3ac9 +Revises: 4d007819e61a +Create Date: 2024-12-10 15:50:48.024204+00:00 + +""" +from alembic import op +from sqlalchemy.sql import expression + +# revision identifiers, used by Alembic. +revision = "5e27063c3ac9" +down_revision = "4d007819e61a" +branch_labels = None +depends_on = None + + +def upgrade(): + # Change the server_default of privacy_hide_email to false + with op.batch_alter_table("users") as batch_op: + batch_op.alter_column("privacy_hide_email", server_default=expression.false()) + + # Reset all to default: Update existing values in the database + op.execute("UPDATE users SET privacy_hide_email = false") + + +def downgrade(): + + # Revert the server_default of privacy_hide_email to true + with op.batch_alter_table("users") as batch_op: + batch_op.alter_column("privacy_hide_email", server_default=expression.true()) + + # Reset all to default: Revert existing values in the database to true + op.execute("UPDATE users SET privacy_hide_email = true") diff --git a/packages/postgres-database/src/simcore_postgres_database/models/users.py b/packages/postgres-database/src/simcore_postgres_database/models/users.py index d42568d772f..bdff1293211 100644 --- a/packages/postgres-database/src/simcore_postgres_database/models/users.py +++ b/packages/postgres-database/src/simcore_postgres_database/models/users.py @@ -161,7 +161,7 @@ class UserStatus(str, Enum): "privacy_hide_email", sa.Boolean, nullable=False, - server_default=expression.true(), + server_default=expression.false(), doc="If true, it hides users.email to others", ), # diff --git a/packages/postgres-database/src/simcore_postgres_database/utils_products.py b/packages/postgres-database/src/simcore_postgres_database/utils_products.py index ff87ac1ad00..33e877c21d0 100644 --- a/packages/postgres-database/src/simcore_postgres_database/utils_products.py +++ b/packages/postgres-database/src/simcore_postgres_database/utils_products.py @@ -2,6 +2,8 @@ """ +import warnings + import sqlalchemy as sa from ._protocols import AiopgConnection, DBConnection @@ -37,36 +39,54 @@ async def get_product_group_id( return None if group_id is None else _GroupID(group_id) +async def execute_get_or_create_product_group(conn, product_name: str) -> int: + # + # NOTE: Separated so it can be used in asyncpg and aiopg environs while both + # coexist + # + group_id: int | None = await conn.scalar( + sa.select(products.c.group_id) + .where(products.c.name == product_name) + .with_for_update(read=True) + # a `FOR SHARE` lock: locks changes in the product until transaction is done. + # Read might return in None, but it is OK + ) + if group_id is None: + group_id = await conn.scalar( + groups.insert() + .values( + name=product_name, + description=f"{product_name} product group", + type=GroupType.STANDARD, + ) + .returning(groups.c.gid) + ) + assert group_id # nosec + + await conn.execute( + products.update() + .where(products.c.name == product_name) + .values(group_id=group_id) + ) + + return group_id + + async def get_or_create_product_group( connection: AiopgConnection, product_name: str ) -> _GroupID: """ Returns group_id of a product. Creates it if undefined """ + warnings.warn( + f"{__name__}.get_or_create_product_group uses aiopg which has been deprecated in this repo. Please use the asyncpg equivalent version instead" + "See https://github.com/ITISFoundation/osparc-simcore/issues/4529", + DeprecationWarning, + stacklevel=1, + ) + async with connection.begin(): - group_id = await connection.scalar( - sa.select(products.c.group_id) - .where(products.c.name == product_name) - .with_for_update(read=True) - # a `FOR SHARE` lock: locks changes in the product until transaction is done. - # Read might return in None, but it is OK + group_id = await execute_get_or_create_product_group( + connection, product_name=product_name ) - if group_id is None: - group_id = await connection.scalar( - groups.insert() - .values( - name=product_name, - description=f"{product_name} product group", - type=GroupType.STANDARD, - ) - .returning(groups.c.gid) - ) - assert group_id # nosec - - await connection.execute( - products.update() - .where(products.c.name == product_name) - .values(group_id=group_id) - ) - return _GroupID(group_id) diff --git a/packages/postgres-database/src/simcore_postgres_database/utils_users.py b/packages/postgres-database/src/simcore_postgres_database/utils_users.py index 806d950fee5..9026cdd27b4 100644 --- a/packages/postgres-database/src/simcore_postgres_database/utils_users.py +++ b/packages/postgres-database/src/simcore_postgres_database/utils_users.py @@ -36,6 +36,10 @@ def _generate_random_chars(length=5) -> str: return "".join(secrets.choice(string.digits) for _ in range(length - 1)) +def generate_alternative_username(username) -> str: + return f"{username}_{_generate_random_chars()}" + + class UsersRepo: @staticmethod async def new_user( @@ -61,7 +65,7 @@ async def new_user( users.insert().values(**data).returning(users.c.id) ) except UniqueViolation: # noqa: PERF203 - data["name"] = f'{data["name"]}_{_generate_random_chars()}' + data["name"] = generate_alternative_username(data["name"]) result = await conn.execute( sa.select( diff --git a/packages/postgres-database/tests/test_groups.py b/packages/postgres-database/tests/test_models_groups.py similarity index 100% rename from packages/postgres-database/tests/test_groups.py rename to packages/postgres-database/tests/test_models_groups.py diff --git a/packages/pytest-simcore/src/pytest_simcore/simcore_webserver_groups_fixtures.py b/packages/pytest-simcore/src/pytest_simcore/simcore_webserver_groups_fixtures.py new file mode 100644 index 00000000000..0c79aba5622 --- /dev/null +++ b/packages/pytest-simcore/src/pytest_simcore/simcore_webserver_groups_fixtures.py @@ -0,0 +1,158 @@ +# pylint: disable=redefined-outer-name +# pylint: disable=unused-argument +# pylint: disable=unused-variable +""" + + Fixtures for groups + + NOTE: These fixtures are used in integration and unit tests +""" + + +from collections.abc import AsyncIterator +from typing import Any + +import pytest +from aiohttp import web +from aiohttp.test_utils import TestClient +from models_library.api_schemas_webserver.groups import GroupGet +from models_library.groups import GroupsByTypeTuple, StandardGroupCreate +from models_library.users import UserID +from pytest_simcore.helpers.webserver_login import NewUser, UserInfoDict +from simcore_service_webserver.groups._groups_api import ( + add_user_in_group, + create_standard_group, + delete_standard_group, + list_user_groups_with_read_access, +) + + +def _groupget_model_dump(group, access_rights) -> dict[str, Any]: + return GroupGet.from_model(group, access_rights).model_dump( + mode="json", by_alias=True + ) + + +async def _create_organization( + app: web.Application, user_id: UserID, new_group: dict +) -> dict[str, Any]: + group, access_rights = await create_standard_group( + app, + user_id=user_id, + create=StandardGroupCreate.model_validate(new_group), + ) + return _groupget_model_dump(group=group, access_rights=access_rights) + + +# +# USER'S GROUPS FIXTURES +# + + +@pytest.fixture +async def standard_groups_owner( + client: TestClient, + logged_user: UserInfoDict, +) -> AsyncIterator[UserInfoDict]: + """ + standard_groups_owner creates TWO organizations and adds logged_user in them + """ + + assert client.app + # create a separate account to own standard groups + async with NewUser( + { + "name": f"{logged_user['name']}_groups_owner", + "role": "USER", + }, + client.app, + ) as owner_user: + + # creates two groups + sparc_group = await _create_organization( + app=client.app, + user_id=owner_user["id"], + new_group={ + "name": "SPARC", + "description": "Stimulating Peripheral Activity to Relieve Conditions", + "thumbnail": "https://commonfund.nih.gov/sites/default/files/sparc-image-homepage500px.png", + "inclusion_rules": {"email": r"@(sparc)+\.(io|com)$"}, + }, + ) + team_black_group = await _create_organization( + app=client.app, + user_id=owner_user["id"], + new_group={ + "name": "team Black", + "description": "THE incredible black team", + "thumbnail": None, + "inclusion_rules": {"email": r"@(black)+\.(io|com)$"}, + }, + ) + + # adds logged_user to sparc group + await add_user_in_group( + app=client.app, + user_id=owner_user["id"], + group_id=sparc_group["gid"], + new_by_user_id=logged_user["id"], + ) + + # adds logged_user to team-black group + await add_user_in_group( + app=client.app, + user_id=owner_user["id"], + group_id=team_black_group["gid"], + new_by_user_id=logged_user["id"], + ) + + yield owner_user + + # clean groups + await delete_standard_group( + client.app, user_id=owner_user["id"], group_id=sparc_group["gid"] + ) + await delete_standard_group( + client.app, user_id=owner_user["id"], group_id=team_black_group["gid"] + ) + + +@pytest.fixture +async def logged_user_groups_by_type( + client: TestClient, logged_user: UserInfoDict, standard_groups_owner: UserInfoDict +) -> GroupsByTypeTuple: + assert client.app + + assert logged_user["id"] != standard_groups_owner["id"] + + groups_by_type = await list_user_groups_with_read_access( + client.app, user_id=logged_user["id"] + ) + assert groups_by_type.primary + assert groups_by_type.everyone + return groups_by_type + + +@pytest.fixture +def primary_group( + logged_user_groups_by_type: GroupsByTypeTuple, +) -> dict[str, Any]: + """`logged_user`'s primary group""" + assert logged_user_groups_by_type.primary + return _groupget_model_dump(*logged_user_groups_by_type.primary) + + +@pytest.fixture +def standard_groups( + logged_user_groups_by_type: GroupsByTypeTuple, +) -> list[dict[str, Any]]: + """owned by `standard_groups_owner` and shared with `logged_user`""" + return [_groupget_model_dump(*sg) for sg in logged_user_groups_by_type.standard] + + +@pytest.fixture +def all_group( + logged_user_groups_by_type: GroupsByTypeTuple, +) -> dict[str, Any]: + assert logged_user_groups_by_type.everyone + return _groupget_model_dump(*logged_user_groups_by_type.everyone) diff --git a/services/api-server/src/simcore_service_api_server/services/webserver.py b/services/api-server/src/simcore_service_api_server/services/webserver.py index da5c7d20ad8..c7d5680eb37 100644 --- a/services/api-server/src/simcore_service_api_server/services/webserver.py +++ b/services/api-server/src/simcore_service_api_server/services/webserver.py @@ -29,8 +29,10 @@ ProjectInputUpdate, ) from models_library.api_schemas_webserver.resource_usage import PricingPlanGet -from models_library.api_schemas_webserver.users import ProfileGet as WebProfileGet -from models_library.api_schemas_webserver.users import ProfileUpdate as WebProfileUpdate +from models_library.api_schemas_webserver.users import MyProfileGet as WebProfileGet +from models_library.api_schemas_webserver.users import ( + MyProfilePatch as WebProfileUpdate, +) from models_library.api_schemas_webserver.wallets import WalletGet from models_library.generics import Envelope from models_library.projects import ProjectID diff --git a/services/api-server/tests/unit/_with_db/test_api_user.py b/services/api-server/tests/unit/_with_db/test_api_user.py index 24836d1b3cd..93a3bdf8f68 100644 --- a/services/api-server/tests/unit/_with_db/test_api_user.py +++ b/services/api-server/tests/unit/_with_db/test_api_user.py @@ -9,7 +9,7 @@ import pytest import respx from fastapi import FastAPI -from models_library.api_schemas_webserver.users import ProfileGet as WebProfileGet +from models_library.api_schemas_webserver.users import MyProfileGet as WebProfileGet from respx import MockRouter from simcore_service_api_server._meta import API_VTAG from simcore_service_api_server.core.settings import ApplicationSettings diff --git a/services/web/server/VERSION b/services/web/server/VERSION index a758a09aae5..5c4503b7043 100644 --- a/services/web/server/VERSION +++ b/services/web/server/VERSION @@ -1 +1 @@ -0.48.0 +0.49.0 diff --git a/services/web/server/setup.cfg b/services/web/server/setup.cfg index 0b6157ef959..0e40e2535ee 100644 --- a/services/web/server/setup.cfg +++ b/services/web/server/setup.cfg @@ -1,5 +1,5 @@ [bumpversion] -current_version = 0.48.0 +current_version = 0.49.0 commit = True message = services/webserver api version: {current_version} → {new_version} tag = False diff --git a/services/web/server/src/simcore_service_webserver/api/v0/openapi.yaml b/services/web/server/src/simcore_service_webserver/api/v0/openapi.yaml index bc73e5441d2..9a92419e514 100644 --- a/services/web/server/src/simcore_service_webserver/api/v0/openapi.yaml +++ b/services/web/server/src/simcore_service_webserver/api/v0/openapi.yaml @@ -2,7 +2,7 @@ openapi: 3.1.0 info: title: simcore-service-webserver description: Main service with an interface (http-API & websockets) to the web front-end - version: 0.48.0 + version: 0.49.0 servers: - url: '' description: webserver @@ -634,7 +634,8 @@ paths: tags: - groups summary: Add Group User - description: Adds a user to an organization group + description: Adds a user to an organization group using their username, user + ID, or email (subject to privacy settings) operationId: add_group_user parameters: - name: gid @@ -1141,7 +1142,7 @@ paths: content: application/json: schema: - $ref: '#/components/schemas/Envelope_ProfileGet_' + $ref: '#/components/schemas/Envelope_MyProfileGet_' put: tags: - user @@ -1152,7 +1153,7 @@ paths: content: application/json: schema: - $ref: '#/components/schemas/ProfileUpdate' + $ref: '#/components/schemas/MyProfilePatch' required: true responses: '204': @@ -1167,7 +1168,7 @@ paths: content: application/json: schema: - $ref: '#/components/schemas/ProfileUpdate' + $ref: '#/components/schemas/MyProfilePatch' required: true responses: '204': @@ -7989,6 +7990,19 @@ components: title: Error type: object title: Envelope[MyGroupsGet] + Envelope_MyProfileGet_: + properties: + data: + anyOf: + - $ref: '#/components/schemas/MyProfileGet' + - type: 'null' + error: + anyOf: + - {} + - type: 'null' + title: Error + type: object + title: Envelope[MyProfileGet] Envelope_NodeCreated_: properties: data: @@ -8106,19 +8120,6 @@ components: title: Error type: object title: Envelope[PricingUnitGet] - Envelope_ProfileGet_: - properties: - data: - anyOf: - - $ref: '#/components/schemas/ProfileGet' - - type: 'null' - error: - anyOf: - - {} - - type: 'null' - title: Error - type: object - title: Envelope[ProfileGet] Envelope_ProjectGet_: properties: data: @@ -9873,7 +9874,7 @@ components: type: string type: object title: Inclusionrules - description: Maps user's column and regular expression + deprecated: true type: object required: - gid @@ -9911,12 +9912,21 @@ components: minimum: 0 - type: 'null' title: Uid + userName: + anyOf: + - type: string + maxLength: 100 + minLength: 1 + - type: 'null' + title: Username email: anyOf: - type: string format: email - type: 'null' title: Email + description: Accessible only if the user has opted to share their email + in privacy settings type: object title: GroupUserAdd description: "Identify the user with either `email` or `uid` \u2014 only one." @@ -9928,41 +9938,48 @@ components: - type: 'null' title: Id description: the user id + userName: + type: string + maxLength: 100 + minLength: 1 + title: Username + gid: + anyOf: + - type: string + - type: 'null' + title: Gid + description: the user primary gid login: anyOf: - type: string format: email - type: 'null' title: Login - description: the user login email + description: the user's email, if privacy settings allows first_name: anyOf: - type: string - type: 'null' title: First Name - description: the user first name + description: If privacy settings allows last_name: anyOf: - type: string - type: 'null' title: Last Name - description: the user last name + description: If privacy settings allows gravatar_id: anyOf: - type: string - type: 'null' title: Gravatar Id description: the user gravatar id hash - gid: - anyOf: - - type: string - - type: 'null' - title: Gid - description: the user primary gid + deprecated: true accessRights: $ref: '#/components/schemas/GroupAccessRights' type: object required: + - userName - accessRights title: GroupUserGet example: @@ -9976,6 +9993,7 @@ components: id: '1' last_name: Smith login: mr.smith@matrix.com + userName: mrmith GroupUserUpdate: properties: accessRights: @@ -10379,6 +10397,136 @@ components: description: Some foundation gid: '16' label: Blue Fundation + MyProfileGet: + properties: + id: + type: integer + exclusiveMinimum: true + title: Id + minimum: 0 + userName: + type: string + maxLength: 100 + minLength: 1 + title: Username + description: Unique username identifier + first_name: + anyOf: + - type: string + maxLength: 255 + - type: 'null' + title: First Name + last_name: + anyOf: + - type: string + maxLength: 255 + - type: 'null' + title: Last Name + login: + type: string + format: email + title: Login + role: + type: string + enum: + - ANONYMOUS + - GUEST + - USER + - TESTER + - PRODUCT_OWNER + - ADMIN + title: Role + groups: + anyOf: + - $ref: '#/components/schemas/MyGroupsGet' + - type: 'null' + gravatar_id: + anyOf: + - type: string + - type: 'null' + title: Gravatar Id + deprecated: true + expirationDate: + anyOf: + - type: string + format: date + - type: 'null' + title: Expirationdate + description: If user has a trial account, it sets the expiration date, otherwise + None + privacy: + $ref: '#/components/schemas/MyProfilePrivacyGet' + preferences: + additionalProperties: + $ref: '#/components/schemas/Preference' + type: object + title: Preferences + type: object + required: + - id + - userName + - login + - role + - privacy + - preferences + title: MyProfileGet + MyProfilePatch: + properties: + first_name: + anyOf: + - type: string + maxLength: 255 + - type: 'null' + title: First Name + last_name: + anyOf: + - type: string + maxLength: 255 + - type: 'null' + title: Last Name + userName: + anyOf: + - type: string + maxLength: 100 + minLength: 1 + - type: 'null' + title: Username + privacy: + anyOf: + - $ref: '#/components/schemas/MyProfilePrivacyPatch' + - type: 'null' + type: object + title: MyProfilePatch + example: + first_name: Pedro + last_name: Crespo + MyProfilePrivacyGet: + properties: + hideFullname: + type: boolean + title: Hidefullname + hideEmail: + type: boolean + title: Hideemail + type: object + required: + - hideFullname + - hideEmail + title: MyProfilePrivacyGet + MyProfilePrivacyPatch: + properties: + hideFullname: + anyOf: + - type: boolean + - type: 'null' + title: Hidefullname + hideEmail: + anyOf: + - type: boolean + - type: 'null' + title: Hideemail + type: object + title: MyProfilePrivacyPatch Node-Input: properties: key: @@ -11661,136 +11809,6 @@ components: - currentCostPerUnit - default title: PricingUnitGet - ProfileGet: - properties: - id: - type: integer - exclusiveMinimum: true - title: Id - minimum: 0 - userName: - type: string - maxLength: 100 - minLength: 1 - title: Username - description: Unique username identifier - first_name: - anyOf: - - type: string - maxLength: 255 - - type: 'null' - title: First Name - last_name: - anyOf: - - type: string - maxLength: 255 - - type: 'null' - title: Last Name - login: - type: string - format: email - title: Login - role: - type: string - enum: - - ANONYMOUS - - GUEST - - USER - - TESTER - - PRODUCT_OWNER - - ADMIN - title: Role - groups: - anyOf: - - $ref: '#/components/schemas/MyGroupsGet' - - type: 'null' - gravatar_id: - anyOf: - - type: string - - type: 'null' - title: Gravatar Id - deprecated: true - expirationDate: - anyOf: - - type: string - format: date - - type: 'null' - title: Expirationdate - description: If user has a trial account, it sets the expiration date, otherwise - None - privacy: - $ref: '#/components/schemas/ProfilePrivacyGet' - preferences: - additionalProperties: - $ref: '#/components/schemas/Preference' - type: object - title: Preferences - type: object - required: - - id - - userName - - login - - role - - privacy - - preferences - title: ProfileGet - ProfilePrivacyGet: - properties: - hideFullname: - type: boolean - title: Hidefullname - hideEmail: - type: boolean - title: Hideemail - type: object - required: - - hideFullname - - hideEmail - title: ProfilePrivacyGet - ProfilePrivacyUpdate: - properties: - hideFullname: - anyOf: - - type: boolean - - type: 'null' - title: Hidefullname - hideEmail: - anyOf: - - type: boolean - - type: 'null' - title: Hideemail - type: object - title: ProfilePrivacyUpdate - ProfileUpdate: - properties: - first_name: - anyOf: - - type: string - maxLength: 255 - - type: 'null' - title: First Name - last_name: - anyOf: - - type: string - maxLength: 255 - - type: 'null' - title: Last Name - userName: - anyOf: - - type: string - maxLength: 100 - minLength: 1 - - type: 'null' - title: Username - privacy: - anyOf: - - $ref: '#/components/schemas/ProfilePrivacyUpdate' - - type: 'null' - type: object - title: ProfileUpdate - example: - first_name: Pedro - last_name: Crespo ProjectCopyOverride: properties: name: diff --git a/services/web/server/src/simcore_service_webserver/garbage_collector/_core_utils.py b/services/web/server/src/simcore_service_webserver/garbage_collector/_core_utils.py index 528fed2e3c5..53750a3c27d 100644 --- a/services/web/server/src/simcore_service_webserver/garbage_collector/_core_utils.py +++ b/services/web/server/src/simcore_service_webserver/garbage_collector/_core_utils.py @@ -78,7 +78,7 @@ async def get_new_project_owner_gid( standard_groups = {} # groups of users, multiple users can be part of this primary_groups = {} # each individual user has a unique primary group for other_gid in other_users_access_rights: - group: Group | None = await get_group_from_gid(app=app, gid=int(other_gid)) + group: Group | None = await get_group_from_gid(app=app, group_id=int(other_gid)) # only process for users and groups with write access right if group is None: diff --git a/services/web/server/src/simcore_service_webserver/groups/_classifiers.py b/services/web/server/src/simcore_service_webserver/groups/_classifiers_api.py similarity index 100% rename from services/web/server/src/simcore_service_webserver/groups/_classifiers.py rename to services/web/server/src/simcore_service_webserver/groups/_classifiers_api.py diff --git a/services/web/server/src/simcore_service_webserver/groups/_classifiers_handlers.py b/services/web/server/src/simcore_service_webserver/groups/_classifiers_handlers.py new file mode 100644 index 00000000000..40ce8c41a34 --- /dev/null +++ b/services/web/server/src/simcore_service_webserver/groups/_classifiers_handlers.py @@ -0,0 +1,112 @@ +import logging + +from aiohttp import web +from servicelib.aiohttp.requests_validation import ( + parse_request_path_parameters_as, + parse_request_query_parameters_as, +) + +from .._meta import API_VTAG +from ..login.decorators import login_required +from ..scicrunch.db import ResearchResourceRepository +from ..scicrunch.errors import ScicrunchError +from ..scicrunch.models import ResearchResource, ResourceHit +from ..scicrunch.service_client import SciCrunch +from ..security.decorators import permission_required +from ..utils_aiohttp import envelope_json_response +from ._classifiers_api import GroupClassifierRepository, build_rrids_tree_view +from ._common.exceptions_handlers import handle_plugin_requests_exceptions +from ._common.schemas import GroupsClassifiersQuery, GroupsPathParams + +_logger = logging.getLogger(__name__) + + +routes = web.RouteTableDef() + + +@routes.get(f"/{API_VTAG}/groups/{{gid}}/classifiers", name="get_group_classifiers") +@login_required +@permission_required("groups.*") +@handle_plugin_requests_exceptions +async def get_group_classifiers(request: web.Request): + try: + path_params = parse_request_path_parameters_as(GroupsPathParams, request) + query_params: GroupsClassifiersQuery = parse_request_query_parameters_as( + GroupsClassifiersQuery, request + ) + + repo = GroupClassifierRepository(request.app) + if not await repo.group_uses_scicrunch(path_params.gid): + bundle = await repo.get_classifiers_from_bundle(path_params.gid) + return envelope_json_response(bundle) + + # otherwise, build dynamic tree with RRIDs + view = await build_rrids_tree_view( + request.app, tree_view_mode=query_params.tree_view + ) + except ScicrunchError: + view = {} + + return envelope_json_response(view) + + +@routes.get( + f"/{API_VTAG}/groups/sparc/classifiers/scicrunch-resources/{{rrid}}", + name="get_scicrunch_resource", +) +@login_required +@permission_required("groups.*") +@handle_plugin_requests_exceptions +async def get_scicrunch_resource(request: web.Request): + rrid = request.match_info["rrid"] + rrid = SciCrunch.validate_identifier(rrid) + + # check if in database first + repo = ResearchResourceRepository(request.app) + resource: ResearchResource | None = await repo.get_resource(rrid) + if not resource: + # otherwise, request to scicrunch service + scicrunch = SciCrunch.get_instance(request.app) + resource = await scicrunch.get_resource_fields(rrid) + + return envelope_json_response(resource.model_dump()) + + +@routes.post( + f"/{API_VTAG}/groups/sparc/classifiers/scicrunch-resources/{{rrid}}", + name="add_scicrunch_resource", +) +@login_required +@permission_required("groups.*") +@handle_plugin_requests_exceptions +async def add_scicrunch_resource(request: web.Request): + rrid = request.match_info["rrid"] + + # check if exists + repo = ResearchResourceRepository(request.app) + resource: ResearchResource | None = await repo.get_resource(rrid) + if not resource: + # then request scicrunch service + scicrunch = SciCrunch.get_instance(request.app) + resource = await scicrunch.get_resource_fields(rrid) + + # insert new or if exists, then update + await repo.upsert(resource) + + return envelope_json_response(resource.model_dump()) + + +@routes.get( + f"/{API_VTAG}/groups/sparc/classifiers/scicrunch-resources:search", + name="search_scicrunch_resources", +) +@login_required +@permission_required("groups.*") +@handle_plugin_requests_exceptions +async def search_scicrunch_resources(request: web.Request): + guess_name = str(request.query["guess_name"]).strip() + + scicrunch = SciCrunch.get_instance(request.app) + hits: list[ResourceHit] = await scicrunch.search_resource(guess_name) + + return envelope_json_response([hit.model_dump() for hit in hits]) diff --git a/services/web/server/src/simcore_service_webserver/groups/_common/__init__.py b/services/web/server/src/simcore_service_webserver/groups/_common/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/services/web/server/src/simcore_service_webserver/groups/_common/exceptions_handlers.py b/services/web/server/src/simcore_service_webserver/groups/_common/exceptions_handlers.py new file mode 100644 index 00000000000..f0b9242fb70 --- /dev/null +++ b/services/web/server/src/simcore_service_webserver/groups/_common/exceptions_handlers.py @@ -0,0 +1,59 @@ +import logging + +from servicelib.aiohttp import status + +from ...exception_handling import ( + ExceptionToHttpErrorMap, + HttpErrorInfo, + exception_handling_decorator, + to_exceptions_handlers_map, +) +from ...scicrunch.errors import InvalidRRIDError, ScicrunchError +from ...users.exceptions import UserNotFoundError +from ..exceptions import ( + GroupNotFoundError, + UserAlreadyInGroupError, + UserInGroupNotFoundError, + UserInsufficientRightsError, +) + +_logger = logging.getLogger(__name__) + + +_TO_HTTP_ERROR_MAP: ExceptionToHttpErrorMap = { + UserNotFoundError: HttpErrorInfo( + status.HTTP_404_NOT_FOUND, + "User {uid} or {email} not found", + ), + GroupNotFoundError: HttpErrorInfo( + status.HTTP_404_NOT_FOUND, + "Group {gid} not found", + ), + UserInGroupNotFoundError: HttpErrorInfo( + status.HTTP_404_NOT_FOUND, + "User not found in group {gid}", + ), + UserAlreadyInGroupError: HttpErrorInfo( + status.HTTP_409_CONFLICT, + "User is already in group {gid}", + ), + UserInsufficientRightsError: HttpErrorInfo( + status.HTTP_403_FORBIDDEN, + "Insufficient rights for {permission} access to group {gid}", + ), + # scicrunch + InvalidRRIDError: HttpErrorInfo( + status.HTTP_409_CONFLICT, + "Invalid RRID {rrid}", + ), + ScicrunchError: HttpErrorInfo( + status.HTTP_409_CONFLICT, + "Cannot get RRID since scicrunch.org service is not reachable.", + ), +} + + +handle_plugin_requests_exceptions = exception_handling_decorator( + to_exceptions_handlers_map(_TO_HTTP_ERROR_MAP) +) +# this is one decorator with a single exception handler diff --git a/services/web/server/src/simcore_service_webserver/groups/_common/schemas.py b/services/web/server/src/simcore_service_webserver/groups/_common/schemas.py new file mode 100644 index 00000000000..872193aaffe --- /dev/null +++ b/services/web/server/src/simcore_service_webserver/groups/_common/schemas.py @@ -0,0 +1,25 @@ +from typing import Literal + +from models_library.rest_base import RequestParameters, StrictRequestParameters +from models_library.users import GroupID, UserID +from pydantic import Field + +from ..._constants import RQ_PRODUCT_KEY, RQT_USERID_KEY + + +class GroupsRequestContext(RequestParameters): + user_id: UserID = Field(..., alias=RQT_USERID_KEY) # type: ignore[literal-required] + product_name: str = Field(..., alias=RQ_PRODUCT_KEY) # type: ignore[literal-required] + + +class GroupsPathParams(StrictRequestParameters): + gid: GroupID + + +class GroupsUsersPathParams(StrictRequestParameters): + gid: GroupID + uid: UserID + + +class GroupsClassifiersQuery(RequestParameters): + tree_view: Literal["std"] = "std" diff --git a/services/web/server/src/simcore_service_webserver/groups/_db.py b/services/web/server/src/simcore_service_webserver/groups/_db.py deleted file mode 100644 index 3bcee2c6591..00000000000 --- a/services/web/server/src/simcore_service_webserver/groups/_db.py +++ /dev/null @@ -1,428 +0,0 @@ -import re -from typing import Any - -import sqlalchemy as sa -from aiopg.sa import SAConnection -from aiopg.sa.result import ResultProxy, RowProxy -from models_library.groups import GroupAtDB -from models_library.users import GroupID, UserID -from simcore_postgres_database.errors import UniqueViolation -from simcore_postgres_database.utils_products import get_or_create_product_group -from sqlalchemy import and_, literal_column -from sqlalchemy.dialects.postgresql import insert - -from ..db.models import GroupType, groups, user_to_groups, users -from ..users.exceptions import UserNotFoundError -from ._users import convert_user_in_group_to_schema -from ._utils import ( - AccessRightsDict, - check_group_permissions, - convert_groups_db_to_schema, - convert_groups_schema_to_db, -) -from .exceptions import ( - GroupNotFoundError, - UserAlreadyInGroupError, - UserInGroupNotFoundError, -) - -_DEFAULT_PRODUCT_GROUP_ACCESS_RIGHTS = AccessRightsDict( - read=False, - write=False, - delete=False, -) - -_DEFAULT_GROUP_READ_ACCESS_RIGHTS = AccessRightsDict( - read=True, - write=False, - delete=False, -) -_DEFAULT_GROUP_OWNER_ACCESS_RIGHTS = AccessRightsDict( - read=True, - write=True, - delete=True, -) - - -async def _get_user_group( - conn: SAConnection, user_id: UserID, gid: GroupID -) -> RowProxy: - result = await conn.execute( - sa.select(groups, user_to_groups.c.access_rights) - .select_from(user_to_groups.join(groups, user_to_groups.c.gid == groups.c.gid)) - .where(and_(user_to_groups.c.uid == user_id, user_to_groups.c.gid == gid)) - ) - group = await result.fetchone() - if not group: - raise GroupNotFoundError(gid=gid) - assert isinstance(group, RowProxy) # nosec - return group - - -async def get_user_from_email(conn: SAConnection, email: str) -> RowProxy: - result = await conn.execute(sa.select(users).where(users.c.email == email)) - user = await result.fetchone() - if not user: - raise UserNotFoundError(email=email) - assert isinstance(user, RowProxy) # nosec - return user - - -# -# USER GROUPS: standard operations -# - - -async def get_all_user_groups_with_read_access( - conn: SAConnection, user_id: UserID -) -> tuple[dict[str, Any], list[dict[str, Any]], dict[str, Any]]: - """ - Returns the user primary group, standard groups and the all group - """ - primary_group = {} - user_groups = [] - all_group = {} - - query = ( - sa.select(groups, user_to_groups.c.access_rights) - .select_from( - user_to_groups.join(groups, user_to_groups.c.gid == groups.c.gid), - ) - .where(user_to_groups.c.uid == user_id) - ) - row: RowProxy - async for row in conn.execute(query): - if row.type == GroupType.EVERYONE: - assert row.access_rights["read"] # nosec - all_group = convert_groups_db_to_schema(row) - - elif row.type == GroupType.PRIMARY: - assert row.access_rights["read"] # nosec - primary_group = convert_groups_db_to_schema(row) - - else: - assert row.type == GroupType.STANDARD # nosec - # only add if user has read access - if row.access_rights["read"]: - user_groups.append(convert_groups_db_to_schema(row)) - - return (primary_group, user_groups, all_group) - - -async def get_all_user_groups(conn: SAConnection, user_id: UserID) -> list[GroupAtDB]: - """ - Returns all user groups - """ - result = await conn.execute( - sa.select(groups) - .select_from( - user_to_groups.join(groups, user_to_groups.c.gid == groups.c.gid), - ) - .where(user_to_groups.c.uid == user_id) - ) - rows = await result.fetchall() or [] - return [GroupAtDB.model_validate(row) for row in rows] - - -async def get_user_group( - conn: SAConnection, user_id: UserID, gid: GroupID -) -> dict[str, str]: - """ - Gets group gid if user associated to it and has read access - - raises GroupNotFoundError - raises UserInsufficientRightsError - """ - group: RowProxy = await _get_user_group(conn, user_id, gid) - check_group_permissions(group, user_id, gid, "read") - return convert_groups_db_to_schema(group) - - -async def get_product_group_for_user( - conn: SAConnection, user_id: UserID, product_gid: GroupID -) -> dict[str, str]: - """ - Returns product's group if user belongs to it, otherwise it - raises GroupNotFoundError - """ - group: RowProxy = await _get_user_group(conn, user_id, product_gid) - return convert_groups_db_to_schema(group) - - -async def create_user_group( - conn: SAConnection, user_id: UserID, new_group: dict -) -> dict[str, Any]: - result = await conn.execute( - sa.select(users.c.primary_gid).where(users.c.id == user_id) - ) - user: RowProxy | None = await result.fetchone() - if not user: - raise UserNotFoundError(uid=user_id) - result = await conn.execute( - # pylint: disable=no-value-for-parameter - groups.insert() - .values(**convert_groups_schema_to_db(new_group)) - .returning(literal_column("*")) - ) - group: RowProxy | None = await result.fetchone() - assert group # nosec - - await conn.execute( - # pylint: disable=no-value-for-parameter - user_to_groups.insert().values( - uid=user_id, - gid=group.gid, - access_rights=_DEFAULT_GROUP_OWNER_ACCESS_RIGHTS, - ) - ) - return convert_groups_db_to_schema( - group, accessRights=_DEFAULT_GROUP_OWNER_ACCESS_RIGHTS - ) - - -async def update_user_group( - conn: SAConnection, user_id: UserID, gid: GroupID, new_group_values: dict[str, str] -) -> dict[str, str]: - new_values = { - k: v for k, v in convert_groups_schema_to_db(new_group_values).items() if v - } - - group = await _get_user_group(conn, user_id, gid) - check_group_permissions(group, user_id, gid, "write") - - result = await conn.execute( - # pylint: disable=no-value-for-parameter - groups.update() - .values(**new_values) - .where(groups.c.gid == group.gid) - .returning(literal_column("*")) - ) - updated_group = await result.fetchone() - assert updated_group # nosec - - return convert_groups_db_to_schema(updated_group, accessRights=group.access_rights) - - -async def delete_user_group(conn: SAConnection, user_id: UserID, gid: GroupID) -> None: - group = await _get_user_group(conn, user_id, gid) - check_group_permissions(group, user_id, gid, "delete") - - await conn.execute( - # pylint: disable=no-value-for-parameter - groups.delete().where(groups.c.gid == group.gid) - ) - - -# -# USER GROUPS: Custom operations -# - - -async def list_users_in_group( - conn: SAConnection, user_id: UserID, gid: GroupID -) -> list[dict[str, str]]: - # first check if the group exists - group: RowProxy = await _get_user_group(conn, user_id, gid) - check_group_permissions(group, user_id, gid, "read") - # now get the list - query = ( - sa.select(users, user_to_groups.c.access_rights) - .select_from(users.join(user_to_groups)) - .where(user_to_groups.c.gid == gid) - ) - users_list = [ - convert_user_in_group_to_schema(row) async for row in conn.execute(query) - ] - return users_list - - -async def auto_add_user_to_groups(conn: SAConnection, user: dict) -> None: - user_id: UserID = user["id"] - - # auto add user to the groups with the right rules - # get the groups where there are inclusion rules and see if they apply - query = sa.select(groups).where(groups.c.inclusion_rules != {}) - possible_group_ids = set() - async for row in conn.execute(query): - inclusion_rules = row[groups.c.inclusion_rules] - for prop, rule_pattern in inclusion_rules.items(): - if prop not in user: - continue - if re.search(rule_pattern, user[prop]): - possible_group_ids.add(row[groups.c.gid]) - - # now add the user to these groups if possible - for gid in possible_group_ids: - await conn.execute( - # pylint: disable=no-value-for-parameter - insert(user_to_groups) - .values( - uid=user_id, - gid=gid, - access_rights=_DEFAULT_GROUP_READ_ACCESS_RIGHTS, - ) - .on_conflict_do_nothing() # in case the user was already added - ) - - -async def auto_add_user_to_product_group( - conn: SAConnection, user_id: UserID, product_name: str -) -> GroupID: - product_group_id: GroupID = await get_or_create_product_group(conn, product_name) - - await conn.execute( - # pylint: disable=no-value-for-parameter - insert(user_to_groups) - .values( - uid=user_id, - gid=product_group_id, - access_rights=_DEFAULT_PRODUCT_GROUP_ACCESS_RIGHTS, - ) - .on_conflict_do_nothing() # in case the user was already added - ) - return product_group_id - - -async def is_user_by_email_in_group( - conn: SAConnection, email: str, group_id: GroupID -) -> bool: - user_id = await conn.scalar( - sa.select(users.c.id) - .select_from(sa.join(user_to_groups, users, user_to_groups.c.uid == users.c.id)) - .where((users.c.email == email) & (user_to_groups.c.gid == group_id)) - ) - return user_id is not None - - -async def add_new_user_in_group( - conn: SAConnection, - user_id: UserID, - gid: GroupID, - *, - new_user_id: UserID, - access_rights: AccessRightsDict | None = None, -) -> None: - """ - adds new_user (either by id or email) in group (with gid) owned by user_id - """ - - # first check if the group exists - group: RowProxy = await _get_user_group(conn, user_id, gid) - check_group_permissions(group, user_id, gid, "write") - - # now check the new user exists - users_count = await conn.scalar( - sa.select(sa.func.count()).where(users.c.id == new_user_id) - ) - if not users_count: - assert new_user_id is not None # nosec - raise UserInGroupNotFoundError(uid=new_user_id, gid=gid) - - # add the new user to the group now - user_access_rights = _DEFAULT_GROUP_READ_ACCESS_RIGHTS - if access_rights: - user_access_rights.update(access_rights) - - try: - await conn.execute( - # pylint: disable=no-value-for-parameter - user_to_groups.insert().values( - uid=new_user_id, gid=group.gid, access_rights=user_access_rights - ) - ) - except UniqueViolation as exc: - raise UserAlreadyInGroupError( - uid=new_user_id, gid=gid, user_id=user_id, access_rights=access_rights - ) from exc - - -async def _get_user_in_group_permissions( - conn: SAConnection, gid: GroupID, the_user_id_in_group: int -) -> RowProxy: - # now get the user - result = await conn.execute( - sa.select(users, user_to_groups.c.access_rights) - .select_from(users.join(user_to_groups, users.c.id == user_to_groups.c.uid)) - .where(and_(user_to_groups.c.gid == gid, users.c.id == the_user_id_in_group)) - ) - the_user: RowProxy | None = await result.fetchone() - if not the_user: - raise UserInGroupNotFoundError(uid=the_user_id_in_group, gid=gid) - return the_user - - -async def get_user_in_group( - conn: SAConnection, user_id: UserID, gid: GroupID, the_user_id_in_group: int -) -> dict[str, str]: - # first check if the group exists - group: RowProxy = await _get_user_group(conn, user_id, gid) - check_group_permissions(group, user_id, gid, "read") - # get the user with its permissions - the_user: RowProxy = await _get_user_in_group_permissions( - conn, gid, the_user_id_in_group - ) - return convert_user_in_group_to_schema(the_user) - - -async def update_user_in_group( - conn: SAConnection, - user_id: UserID, - gid: GroupID, - the_user_id_in_group: int, - access_rights: dict, -) -> dict[str, str]: - if not access_rights: - msg = f"Cannot update empty {access_rights}" - raise ValueError(msg) - - # first check if the group exists - group: RowProxy = await _get_user_group(conn, user_id, gid) - check_group_permissions(group, user_id, gid, "write") - # now check the user exists - the_user: RowProxy = await _get_user_in_group_permissions( - conn, gid, the_user_id_in_group - ) - # modify the user access rights - new_db_values = {"access_rights": access_rights} - await conn.execute( - # pylint: disable=no-value-for-parameter - user_to_groups.update() - .values(**new_db_values) - .where( - and_( - user_to_groups.c.uid == the_user_id_in_group, - user_to_groups.c.gid == gid, - ) - ) - ) - user = dict(the_user) - user.update(**new_db_values) - return convert_user_in_group_to_schema(user) - - -async def delete_user_in_group( - conn: SAConnection, user_id: UserID, gid: GroupID, the_user_id_in_group: int -) -> None: - # first check if the group exists - group: RowProxy = await _get_user_group(conn, user_id, gid) - check_group_permissions(group, user_id, gid, "write") - # check the user exists - await _get_user_in_group_permissions(conn, gid, the_user_id_in_group) - # delete him/her - await conn.execute( - # pylint: disable=no-value-for-parameter - user_to_groups.delete().where( - and_( - user_to_groups.c.uid == the_user_id_in_group, - user_to_groups.c.gid == gid, - ) - ) - ) - - -async def get_group_from_gid(conn: SAConnection, gid: GroupID) -> GroupAtDB | None: - row: ResultProxy = await conn.execute(groups.select().where(groups.c.gid == gid)) - result = await row.first() - if result: - return GroupAtDB.model_validate(result) - return None diff --git a/services/web/server/src/simcore_service_webserver/groups/_groups_api.py b/services/web/server/src/simcore_service_webserver/groups/_groups_api.py new file mode 100644 index 00000000000..9b9e712df54 --- /dev/null +++ b/services/web/server/src/simcore_service_webserver/groups/_groups_api.py @@ -0,0 +1,271 @@ +from aiohttp import web +from models_library.basic_types import IDStr +from models_library.emails import LowerCaseEmailStr +from models_library.groups import ( + AccessRightsDict, + Group, + GroupMember, + GroupsByTypeTuple, + StandardGroupCreate, + StandardGroupUpdate, +) +from models_library.products import ProductName +from models_library.users import GroupID, UserID +from pydantic import EmailStr + +from ..users.api import get_user +from . import _groups_db +from .exceptions import GroupsError + +# +# GROUPS +# + + +async def get_group_from_gid(app: web.Application, group_id: GroupID) -> Group | None: + group_db = await _groups_db.get_group_from_gid(app, group_id=group_id) + + if group_db: + return Group.model_construct(**group_db.model_dump()) + return None + + +# +# USER GROUPS: groups a user belongs to +# + + +async def list_user_groups_with_read_access( + app: web.Application, *, user_id: UserID +) -> GroupsByTypeTuple: + """ + Returns the user primary group, standard groups and the all group + """ + # NOTE: Careful! It seems we are filtering out groups, such as Product Groups, + # because they do not have read access. I believe this was done because the + # frontend did not want to display them. + return await _groups_db.get_all_user_groups_with_read_access(app, user_id=user_id) + + +async def list_user_groups_ids_with_read_access( + app: web.Application, *, user_id: UserID +) -> list[GroupID]: + return await _groups_db.get_ids_of_all_user_groups_with_read_access( + app, user_id=user_id + ) + + +async def list_all_user_groups_ids( + app: web.Application, *, user_id: UserID +) -> list[GroupID]: + return await _groups_db.get_ids_of_all_user_groups(app, user_id=user_id) + + +async def get_product_group_for_user( + app: web.Application, *, user_id: UserID, product_gid: GroupID +) -> tuple[Group, AccessRightsDict]: + """ + Returns product's group if user belongs to it, otherwise it + raises GroupNotFoundError + """ + return await _groups_db.get_product_group_for_user( + app, user_id=user_id, product_gid=product_gid + ) + + +# +# CRUD operations on groups linked to a user +# + + +async def create_standard_group( + app: web.Application, + *, + user_id: UserID, + create: StandardGroupCreate, +) -> tuple[Group, AccessRightsDict]: + """NOTE: creation/update and deletion restricted to STANDARD groups + + raises GroupNotFoundError + raises UserInsufficientRightsError: needs WRITE access + """ + return await _groups_db.create_standard_group( + app, + user_id=user_id, + create=create, + ) + + +async def get_associated_group( + app: web.Application, + *, + user_id: UserID, + group_id: GroupID, +) -> tuple[Group, AccessRightsDict]: + """NOTE: here it can also be a non-standard group + + raises GroupNotFoundError + raises UserInsufficientRightsError: needs READ access + """ + return await _groups_db.get_user_group(app, user_id=user_id, group_id=group_id) + + +async def update_standard_group( + app: web.Application, + *, + user_id: UserID, + group_id: GroupID, + update: StandardGroupUpdate, +) -> tuple[Group, AccessRightsDict]: + """NOTE: creation/update and deletion restricted to STANDARD groups + + raises GroupNotFoundError + raises UserInsufficientRightsError: needs WRITE access + """ + + return await _groups_db.update_standard_group( + app, + user_id=user_id, + group_id=group_id, + update=update, + ) + + +async def delete_standard_group( + app: web.Application, *, user_id: UserID, group_id: GroupID +) -> None: + """NOTE: creation/update and deletion restricted to STANDARD groups + + raises GroupNotFoundError + raises UserInsufficientRightsError: needs DELETE access + """ + return await _groups_db.delete_standard_group( + app, user_id=user_id, group_id=group_id + ) + + +# +# GROUP MEMBERS (= a user with some access-rights to a group) +# + + +async def list_group_members( + app: web.Application, user_id: UserID, group_id: GroupID +) -> list[GroupMember]: + return await _groups_db.list_users_in_group(app, user_id=user_id, group_id=group_id) + + +async def get_group_member( + app: web.Application, + user_id: UserID, + group_id: GroupID, + the_user_id_in_group: UserID, +) -> GroupMember: + + return await _groups_db.get_user_in_group( + app, + user_id=user_id, + group_id=group_id, + the_user_id_in_group=the_user_id_in_group, + ) + + +async def update_group_member( + app: web.Application, + user_id: UserID, + group_id: GroupID, + the_user_id_in_group: UserID, + access_rights: AccessRightsDict, +) -> GroupMember: + return await _groups_db.update_user_in_group( + app, + user_id=user_id, + group_id=group_id, + the_user_id_in_group=the_user_id_in_group, + access_rights=access_rights, + ) + + +async def delete_group_member( + app: web.Application, + user_id: UserID, + group_id: GroupID, + the_user_id_in_group: UserID, +) -> None: + return await _groups_db.delete_user_from_group( + app, + user_id=user_id, + group_id=group_id, + the_user_id_in_group=the_user_id_in_group, + ) + + +async def is_user_by_email_in_group( + app: web.Application, user_email: LowerCaseEmailStr, group_id: GroupID +) -> bool: + + return await _groups_db.is_user_by_email_in_group( + app, + email=user_email, + group_id=group_id, + ) + + +async def auto_add_user_to_groups(app: web.Application, user_id: UserID) -> None: + user: dict = await get_user(app, user_id) + return await _groups_db.auto_add_user_to_groups(app, user=user) + + +async def auto_add_user_to_product_group( + app: web.Application, + user_id: UserID, + product_name: ProductName, +) -> GroupID: + return await _groups_db.auto_add_user_to_product_group( + app, user_id=user_id, product_name=product_name + ) + + +def _only_one_true(*args): + return sum(bool(arg) for arg in args) == 1 + + +async def add_user_in_group( + app: web.Application, + user_id: UserID, + group_id: GroupID, + *, + # identifies + new_by_user_id: UserID | None = None, + new_by_user_name: IDStr | None = None, + new_by_user_email: EmailStr | None = None, + # payload + access_rights: AccessRightsDict | None = None, +) -> None: + """Adds new_user (either by id or email) in group (with gid) owned by user_id + + Raises: + UserInGroupNotFoundError + GroupsException + """ + if not _only_one_true(new_by_user_id, new_by_user_name, new_by_user_email): + msg = "Invalid method call, required one of these: user id, username or user email, none provided" + raise GroupsError(msg=msg) + + if new_by_user_email: + user = await _groups_db.get_user_from_email( + app, email=new_by_user_email, caller_user_id=user_id + ) + new_by_user_id = user.id + + if not new_by_user_id: + msg = "Missing new user in arguments" + raise GroupsError(msg=msg) + + return await _groups_db.add_new_user_in_group( + app, + user_id=user_id, + group_id=group_id, + new_user_id=new_by_user_id, + access_rights=access_rights, + ) diff --git a/services/web/server/src/simcore_service_webserver/groups/_groups_db.py b/services/web/server/src/simcore_service_webserver/groups/_groups_db.py new file mode 100644 index 00000000000..570375f3646 --- /dev/null +++ b/services/web/server/src/simcore_service_webserver/groups/_groups_db.py @@ -0,0 +1,749 @@ +import re +from copy import deepcopy + +import sqlalchemy as sa +from aiohttp import web +from models_library.basic_types import IDStr +from models_library.groups import ( + AccessRightsDict, + Group, + GroupInfoTuple, + GroupMember, + GroupsByTypeTuple, + StandardGroupCreate, + StandardGroupUpdate, +) +from models_library.users import GroupID, UserID +from simcore_postgres_database.errors import UniqueViolation +from simcore_postgres_database.models.groups import GroupType +from simcore_postgres_database.utils_products import execute_get_or_create_product_group +from simcore_postgres_database.utils_repos import ( + pass_or_acquire_connection, + transaction_context, +) +from sqlalchemy import and_ +from sqlalchemy.dialects.postgresql import insert +from sqlalchemy.engine.row import Row +from sqlalchemy.ext.asyncio import AsyncConnection + +from ..db.models import GroupType, groups, user_to_groups, users +from ..db.plugin import get_asyncpg_engine +from ..users.exceptions import UserNotFoundError +from .exceptions import ( + GroupNotFoundError, + UserAlreadyInGroupError, + UserInGroupNotFoundError, + UserInsufficientRightsError, +) + +_DEFAULT_PRODUCT_GROUP_ACCESS_RIGHTS = AccessRightsDict( + read=False, + write=False, + delete=False, +) + +_DEFAULT_GROUP_READ_ACCESS_RIGHTS = AccessRightsDict( + read=True, + write=False, + delete=False, +) +_DEFAULT_GROUP_OWNER_ACCESS_RIGHTS = AccessRightsDict( + read=True, + write=True, + delete=True, +) + +_GROUP_COLUMNS = ( + groups.c.gid, + groups.c.name, + groups.c.description, + groups.c.thumbnail, + groups.c.type, + groups.c.inclusion_rules, + # NOTE: drops timestamps +) + + +def _row_to_model(group: Row) -> Group: + return Group( + gid=group.gid, + name=group.name, + description=group.description, + thumbnail=group.thumbnail, + group_type=group.type, + inclusion_rules=group.inclusion_rules, + ) + + +def _to_group_info_tuple(group: Row) -> GroupInfoTuple: + return ( + _row_to_model(group), + AccessRightsDict( + read=group.access_rights["read"], + write=group.access_rights["write"], + delete=group.access_rights["delete"], + ), + ) + + +def _check_group_permissions( + group: Row, user_id: int, gid: int, permission: str +) -> None: + if not group.access_rights[permission]: + raise UserInsufficientRightsError( + user_id=user_id, gid=gid, permission=permission + ) + + +async def _get_group_and_access_rights_or_raise( + conn: AsyncConnection, + *, + user_id: UserID, + gid: GroupID, +) -> Row: + result = await conn.stream( + sa.select( + *_GROUP_COLUMNS, + user_to_groups.c.access_rights, + ) + .select_from(user_to_groups.join(groups, user_to_groups.c.gid == groups.c.gid)) + .where((user_to_groups.c.uid == user_id) & (user_to_groups.c.gid == gid)) + ) + row = await result.fetchone() + if not row: + raise GroupNotFoundError(gid=gid) + return row + + +# +# GROUPS +# + + +async def get_group_from_gid( + app: web.Application, + connection: AsyncConnection | None = None, + *, + group_id: GroupID, +) -> Group | None: + async with pass_or_acquire_connection(get_asyncpg_engine(app), connection) as conn: + row = await conn.stream(groups.select().where(groups.c.gid == group_id)) + result = await row.first() + if result: + return Group.model_validate(result, from_attributes=True) + return None + + +# +# USER's GROUPS +# + + +def _list_user_groups_with_read_access_query(*group_selection, user_id: UserID): + return ( + sa.select(*group_selection, user_to_groups.c.access_rights) + .select_from( + user_to_groups.join(groups, user_to_groups.c.gid == groups.c.gid), + ) + .where( + (user_to_groups.c.uid == user_id) + & (user_to_groups.c.access_rights["read"].astext == "true") + ) + ) + + +async def get_all_user_groups_with_read_access( + app: web.Application, + connection: AsyncConnection | None = None, + *, + user_id: UserID, +) -> GroupsByTypeTuple: + + """ + Returns the user primary group, standard groups and the all group + """ + primary_group: GroupInfoTuple | None = None + standard_groups: list[GroupInfoTuple] = [] + everyone_group: GroupInfoTuple | None = None + + query = _list_user_groups_with_read_access_query(groups, user_id=user_id) + + async with pass_or_acquire_connection(get_asyncpg_engine(app), connection) as conn: + result = await conn.stream(query) + async for row in result: + if row.type == GroupType.EVERYONE: + assert row.access_rights["read"] # nosec + everyone_group = _to_group_info_tuple(row) + + elif row.type == GroupType.PRIMARY: + assert row.access_rights["read"] # nosec + primary_group = _to_group_info_tuple(row) + + else: + assert row.type == GroupType.STANDARD # nosec + # only add if user has read access + if row.access_rights["read"]: + standard_groups.append(_to_group_info_tuple(row)) + + return GroupsByTypeTuple( + primary=primary_group, standard=standard_groups, everyone=everyone_group + ) + + +async def get_ids_of_all_user_groups_with_read_access( + app: web.Application, + connection: AsyncConnection | None = None, + *, + user_id: UserID, +) -> list[GroupID]: + # thin version of `get_all_user_groups_with_read_access` + + query = _list_user_groups_with_read_access_query(groups.c.gid, user_id=user_id) + + async with pass_or_acquire_connection(get_asyncpg_engine(app), connection) as conn: + result = await conn.stream(query) + return [row.gid async for row in result] + + +async def get_all_user_groups( + app: web.Application, + connection: AsyncConnection | None = None, + *, + user_id: UserID, +) -> list[Group]: + """ + Returns all user's groups + """ + async with pass_or_acquire_connection(get_asyncpg_engine(app), connection) as conn: + result = await conn.stream( + sa.select(*_GROUP_COLUMNS) + .select_from( + user_to_groups.join(groups, user_to_groups.c.gid == groups.c.gid), + ) + .where(user_to_groups.c.uid == user_id) + ) + return [Group.model_validate(row) async for row in result] + + +async def get_ids_of_all_user_groups( + app: web.Application, + connection: AsyncConnection | None = None, + *, + user_id: UserID, +) -> list[GroupID]: + # thin version of `get_all_user_groups` + async with pass_or_acquire_connection(get_asyncpg_engine(app), connection) as conn: + result = await conn.stream( + sa.select( + groups.c.gid, + ) + .select_from( + user_to_groups.join(groups, user_to_groups.c.gid == groups.c.gid), + ) + .where(user_to_groups.c.uid == user_id) + ) + return [row.gid async for row in result] + + +async def get_user_group( + app: web.Application, + connection: AsyncConnection | None = None, + *, + user_id: UserID, + group_id: GroupID, +) -> tuple[Group, AccessRightsDict]: + """ + Gets group gid if user associated to it and has read access + + raises GroupNotFoundError + raises UserInsufficientRightsError + """ + async with pass_or_acquire_connection(get_asyncpg_engine(app), connection) as conn: + row = await _get_group_and_access_rights_or_raise( + conn, user_id=user_id, gid=group_id + ) + _check_group_permissions(row, user_id, group_id, "read") + + group, access_rights = _to_group_info_tuple(row) + return group, access_rights + + +async def get_product_group_for_user( + app: web.Application, + connection: AsyncConnection | None = None, + *, + user_id: UserID, + product_gid: GroupID, +) -> tuple[Group, AccessRightsDict]: + """ + Returns product's group if user belongs to it, otherwise it + raises GroupNotFoundError + """ + async with pass_or_acquire_connection(get_asyncpg_engine(app), connection) as conn: + row = await _get_group_and_access_rights_or_raise( + conn, user_id=user_id, gid=product_gid + ) + group, access_rights = _to_group_info_tuple(row) + return group, access_rights + + +assert set(StandardGroupCreate.model_fields).issubset({c.name for c in groups.columns}) + + +async def create_standard_group( + app: web.Application, + connection: AsyncConnection | None = None, + *, + user_id: UserID, + create: StandardGroupCreate, +) -> tuple[Group, AccessRightsDict]: + + async with transaction_context(get_asyncpg_engine(app), connection) as conn: + user = await conn.scalar( + sa.select(users.c.primary_gid).where(users.c.id == user_id) + ) + if not user: + raise UserNotFoundError(uid=user_id) + + result = await conn.stream( + # pylint: disable=no-value-for-parameter + groups.insert() + .values( + **create.model_dump(mode="json", exclude_unset=True), + type=GroupType.STANDARD, + ) + .returning(*_GROUP_COLUMNS) + ) + row = await result.fetchone() + assert row # nosec + + await conn.execute( + # pylint: disable=no-value-for-parameter + user_to_groups.insert().values( + uid=user_id, + gid=row.gid, + access_rights=_DEFAULT_GROUP_OWNER_ACCESS_RIGHTS, + ) + ) + + group = _row_to_model(row) + return group, deepcopy(_DEFAULT_GROUP_OWNER_ACCESS_RIGHTS) + + +assert set(StandardGroupUpdate.model_fields).issubset({c.name for c in groups.columns}) + + +async def update_standard_group( + app: web.Application, + connection: AsyncConnection | None = None, + *, + user_id: UserID, + group_id: GroupID, + update: StandardGroupUpdate, +) -> tuple[Group, AccessRightsDict]: + + values = update.model_dump(mode="json", exclude_unset=True) + + async with transaction_context(get_asyncpg_engine(app), connection) as conn: + row = await _get_group_and_access_rights_or_raise( + conn, user_id=user_id, gid=group_id + ) + assert row.gid == group_id # nosec + _check_group_permissions(row, user_id, group_id, "write") + access_rights = AccessRightsDict(**row.access_rights) # type: ignore[typeddict-item] + + result = await conn.stream( + # pylint: disable=no-value-for-parameter + groups.update() + .values(**values) + .where((groups.c.gid == row.gid) & (groups.c.type == GroupType.STANDARD)) + .returning(*_GROUP_COLUMNS) + ) + row = await result.fetchone() + assert row # nosec + + group = _row_to_model(row) + return group, access_rights + + +async def delete_standard_group( + app: web.Application, + connection: AsyncConnection | None = None, + *, + user_id: UserID, + group_id: GroupID, +) -> None: + async with transaction_context(get_asyncpg_engine(app), connection) as conn: + group = await _get_group_and_access_rights_or_raise( + conn, user_id=user_id, gid=group_id + ) + _check_group_permissions(group, user_id, group_id, "delete") + + await conn.execute( + # pylint: disable=no-value-for-parameter + groups.delete().where( + (groups.c.gid == group.gid) & (groups.c.type == GroupType.STANDARD) + ) + ) + + +# +# USERS +# + + +async def get_user_from_email( + app: web.Application, + connection: AsyncConnection | None = None, + *, + caller_user_id: UserID, + email: str, +) -> Row: + """ + Raises: + UserNotFoundError: if not found or privacy hides email + + """ + async with pass_or_acquire_connection(get_asyncpg_engine(app), connection) as conn: + result = await conn.stream( + sa.select(users.c.id).where( + (users.c.email == email) + & ( + users.c.privacy_hide_email.is_(False) + | (users.c.id == caller_user_id) + ) + ) + ) + user = await result.fetchone() + if not user: + raise UserNotFoundError(email=email) + return user + + +# +# GROUP MEMBERS - CRUD +# + + +def _group_user_cols(caller_user_id: int): + return ( + users.c.id, + users.c.name, + # privacy settings + sa.case( + ( + users.c.privacy_hide_email.is_(True) & (users.c.id != caller_user_id), + None, + ), + else_=users.c.email, + ).label("email"), + sa.case( + ( + users.c.privacy_hide_fullname.is_(True) + & (users.c.id != caller_user_id), + None, + ), + else_=users.c.first_name, + ).label("first_name"), + sa.case( + ( + users.c.privacy_hide_fullname.is_(True) + & (users.c.id != caller_user_id), + None, + ), + else_=users.c.last_name, + ).label("last_name"), + users.c.primary_gid, + ) + + +async def _get_user_in_group( + conn: AsyncConnection, *, caller_user_id, group_id: GroupID, user_id: int +) -> Row: + # now get the user + result = await conn.stream( + sa.select(*_group_user_cols(caller_user_id), user_to_groups.c.access_rights) + .select_from( + users.join(user_to_groups, users.c.id == user_to_groups.c.uid), + ) + .where(and_(user_to_groups.c.gid == group_id, users.c.id == user_id)) + ) + row = await result.fetchone() + if not row: + raise UserInGroupNotFoundError(uid=user_id, gid=group_id) + return row + + +async def list_users_in_group( + app: web.Application, + connection: AsyncConnection | None = None, + *, + user_id: UserID, + group_id: GroupID, +) -> list[GroupMember]: + async with pass_or_acquire_connection(get_asyncpg_engine(app), connection) as conn: + # first check if the group exists + group = await _get_group_and_access_rights_or_raise( + conn, user_id=user_id, gid=group_id + ) + _check_group_permissions(group, user_id, group_id, "read") + + # now get the list + query = ( + sa.select( + *_group_user_cols(user_id), + user_to_groups.c.access_rights, + ) + .select_from(users.join(user_to_groups)) + .where(user_to_groups.c.gid == group_id) + ) + + result = await conn.stream(query) + return [GroupMember.model_validate(row) async for row in result] + + +async def get_user_in_group( + app: web.Application, + connection: AsyncConnection | None = None, + *, + user_id: UserID, + group_id: GroupID, + the_user_id_in_group: int, +) -> GroupMember: + async with pass_or_acquire_connection(get_asyncpg_engine(app), connection) as conn: + # first check if the group exists + group = await _get_group_and_access_rights_or_raise( + conn, user_id=user_id, gid=group_id + ) + _check_group_permissions(group, user_id, group_id, "read") + + # get the user with its permissions + the_user = await _get_user_in_group( + conn, + caller_user_id=user_id, + group_id=group_id, + user_id=the_user_id_in_group, + ) + return GroupMember.model_validate(the_user) + + +async def update_user_in_group( + app: web.Application, + connection: AsyncConnection | None = None, + *, + user_id: UserID, + group_id: GroupID, + the_user_id_in_group: UserID, + access_rights: AccessRightsDict, +) -> GroupMember: + if not access_rights: + msg = f"Cannot update empty {access_rights}" + raise ValueError(msg) + + async with transaction_context(get_asyncpg_engine(app), connection) as conn: + + # first check if the group exists + group = await _get_group_and_access_rights_or_raise( + conn, user_id=user_id, gid=group_id + ) + _check_group_permissions(group, user_id, group_id, "write") + + # now check the user exists + the_user = await _get_user_in_group( + conn, + caller_user_id=user_id, + group_id=group_id, + user_id=the_user_id_in_group, + ) + + # modify the user access rights + new_db_values = {"access_rights": access_rights} + await conn.execute( + # pylint: disable=no-value-for-parameter + user_to_groups.update() + .values(**new_db_values) + .where( + and_( + user_to_groups.c.uid == the_user_id_in_group, + user_to_groups.c.gid == group_id, + ) + ) + ) + user = the_user._asdict() + user.update(**new_db_values) + return GroupMember.model_validate(user) + + +async def delete_user_from_group( + app: web.Application, + connection: AsyncConnection | None = None, + *, + user_id: UserID, + group_id: GroupID, + the_user_id_in_group: UserID, +) -> None: + async with transaction_context(get_asyncpg_engine(app), connection) as conn: + # first check if the group exists + group = await _get_group_and_access_rights_or_raise( + conn, user_id=user_id, gid=group_id + ) + _check_group_permissions(group, user_id, group_id, "write") + + # check the user exists + await _get_user_in_group( + conn, + caller_user_id=user_id, + group_id=group_id, + user_id=the_user_id_in_group, + ) + + # delete him/her + await conn.execute( + # pylint: disable=no-value-for-parameter + user_to_groups.delete().where( + and_( + user_to_groups.c.uid == the_user_id_in_group, + user_to_groups.c.gid == group_id, + ) + ) + ) + + +# +# GROUP MEMBERS - CUSTOM +# + + +async def is_user_by_email_in_group( + app: web.Application, + connection: AsyncConnection | None = None, + *, + email: str, + group_id: GroupID, +) -> bool: + async with pass_or_acquire_connection(get_asyncpg_engine(app), connection) as conn: + user_id = await conn.scalar( + sa.select(users.c.id) + .select_from( + sa.join(user_to_groups, users, user_to_groups.c.uid == users.c.id) + ) + .where((users.c.email == email) & (user_to_groups.c.gid == group_id)) + ) + return user_id is not None + + +async def add_new_user_in_group( + app: web.Application, + connection: AsyncConnection | None = None, + *, + user_id: UserID, + group_id: GroupID, + # either user_id or user_name + new_user_id: UserID | None = None, + new_user_name: IDStr | None = None, + access_rights: AccessRightsDict | None = None, +) -> None: + """ + adds new_user (either by id or email) in group (with gid) owned by user_id + """ + async with transaction_context(get_asyncpg_engine(app), connection) as conn: + # first check if the group exists + group = await _get_group_and_access_rights_or_raise( + conn, user_id=user_id, gid=group_id + ) + _check_group_permissions(group, user_id, group_id, "write") + + query = sa.select(sa.func.count()) + if new_user_id: + query = query.where(users.c.id == new_user_id) + elif new_user_name: + query = query.where(users.c.name == new_user_name) + else: + msg = "Either user name or id but none provided" + raise ValueError(msg) + + # now check the new user exists + users_count = await conn.scalar(query) + if not users_count: + assert new_user_id is not None # nosec + raise UserInGroupNotFoundError(uid=new_user_id, gid=group_id) + + # add the new user to the group now + user_access_rights = _DEFAULT_GROUP_READ_ACCESS_RIGHTS + if access_rights: + user_access_rights.update(access_rights) + + try: + await conn.execute( + # pylint: disable=no-value-for-parameter + user_to_groups.insert().values( + uid=new_user_id, gid=group.gid, access_rights=user_access_rights + ) + ) + except UniqueViolation as exc: + raise UserAlreadyInGroupError( + uid=new_user_id, + gid=group_id, + user_id=user_id, + access_rights=access_rights, + ) from exc + + +async def auto_add_user_to_groups( + app: web.Application, connection: AsyncConnection | None = None, *, user: dict +) -> None: + + user_id: UserID = user["id"] + + # auto add user to the groups with the right rules + # get the groups where there are inclusion rules and see if they apply + query = sa.select(groups).where(groups.c.inclusion_rules != {}) + possible_group_ids = set() + + async with transaction_context(get_asyncpg_engine(app), connection) as conn: + result = await conn.stream(query) + async for row in result: + inclusion_rules = row[groups.c.inclusion_rules] + for prop, rule_pattern in inclusion_rules.items(): + if prop not in user: + continue + if re.search(rule_pattern, user[prop]): + possible_group_ids.add(row[groups.c.gid]) + + # now add the user to these groups if possible + for gid in possible_group_ids: + await conn.execute( + # pylint: disable=no-value-for-parameter + insert(user_to_groups) + .values( + uid=user_id, + gid=gid, + access_rights=_DEFAULT_GROUP_READ_ACCESS_RIGHTS, + ) + .on_conflict_do_nothing() # in case the user was already added + ) + + +async def auto_add_user_to_product_group( + app: web.Application, + connection: AsyncConnection | None = None, + *, + user_id: UserID, + product_name: str, +) -> GroupID: + async with transaction_context(get_asyncpg_engine(app), connection) as conn: + product_group_id: GroupID = await execute_get_or_create_product_group( + conn, product_name + ) + + await conn.execute( + # pylint: disable=no-value-for-parameter + insert(user_to_groups) + .values( + uid=user_id, + gid=product_group_id, + access_rights=_DEFAULT_PRODUCT_GROUP_ACCESS_RIGHTS, + ) + .on_conflict_do_nothing() # in case the user was already added + ) + return product_group_id diff --git a/services/web/server/src/simcore_service_webserver/groups/_groups_handlers.py b/services/web/server/src/simcore_service_webserver/groups/_groups_handlers.py new file mode 100644 index 00000000000..46131510489 --- /dev/null +++ b/services/web/server/src/simcore_service_webserver/groups/_groups_handlers.py @@ -0,0 +1,253 @@ +import logging +from contextlib import suppress + +from aiohttp import web +from models_library.api_schemas_webserver.groups import ( + GroupCreate, + GroupGet, + GroupUpdate, + GroupUserAdd, + GroupUserGet, + GroupUserUpdate, + MyGroupsGet, +) +from servicelib.aiohttp import status +from servicelib.aiohttp.requests_validation import ( + parse_request_body_as, + parse_request_path_parameters_as, +) + +from .._meta import API_VTAG +from ..login.decorators import login_required +from ..products.api import Product, get_current_product +from ..security.decorators import permission_required +from ..utils_aiohttp import envelope_json_response +from . import _groups_api +from ._common.exceptions_handlers import handle_plugin_requests_exceptions +from ._common.schemas import ( + GroupsPathParams, + GroupsRequestContext, + GroupsUsersPathParams, +) +from .exceptions import GroupNotFoundError + +_logger = logging.getLogger(__name__) + + +routes = web.RouteTableDef() + + +@routes.get(f"/{API_VTAG}/groups", name="list_groups") +@login_required +@permission_required("groups.read") +@handle_plugin_requests_exceptions +async def list_groups(request: web.Request): + """ + List all groups (organizations, primary, everyone and products) I belong to + """ + product: Product = get_current_product(request) + req_ctx = GroupsRequestContext.model_validate(request) + + groups_by_type = await _groups_api.list_user_groups_with_read_access( + request.app, user_id=req_ctx.user_id + ) + + assert groups_by_type.primary + assert groups_by_type.everyone + + my_product_group = None + + if product.group_id: + with suppress(GroupNotFoundError): + # Product is optional + my_product_group = await _groups_api.get_product_group_for_user( + app=request.app, + user_id=req_ctx.user_id, + product_gid=product.group_id, + ) + + my_groups = MyGroupsGet( + me=GroupGet.from_model(*groups_by_type.primary), + organizations=[GroupGet.from_model(*gi) for gi in groups_by_type.standard], + all=GroupGet.from_model(*groups_by_type.everyone), + product=GroupGet.from_model(*my_product_group) if my_product_group else None, + ) + + return envelope_json_response(my_groups) + + +# +# ORGANIZATION GROUPS +# + + +@routes.get(f"/{API_VTAG}/groups/{{gid}}", name="get_group") +@login_required +@permission_required("groups.read") +@handle_plugin_requests_exceptions +async def get_group(request: web.Request): + """Get one group details""" + req_ctx = GroupsRequestContext.model_validate(request) + path_params = parse_request_path_parameters_as(GroupsPathParams, request) + + group, access_rights = await _groups_api.get_associated_group( + request.app, user_id=req_ctx.user_id, group_id=path_params.gid + ) + + return envelope_json_response(GroupGet.from_model(group, access_rights)) + + +@routes.post(f"/{API_VTAG}/groups", name="create_group") +@login_required +@permission_required("groups.*") +@handle_plugin_requests_exceptions +async def create_group(request: web.Request): + """Creates a standard group""" + req_ctx = GroupsRequestContext.model_validate(request) + + create = await parse_request_body_as(GroupCreate, request) + + group, access_rights = await _groups_api.create_standard_group( + request.app, + user_id=req_ctx.user_id, + create=create.to_model(), + ) + + created_group = GroupGet.from_model(group, access_rights) + return envelope_json_response(created_group, status_cls=web.HTTPCreated) + + +@routes.patch(f"/{API_VTAG}/groups/{{gid}}", name="update_group") +@login_required +@permission_required("groups.*") +@handle_plugin_requests_exceptions +async def update_group(request: web.Request): + """Updates metadata of a standard group""" + req_ctx = GroupsRequestContext.model_validate(request) + path_params = parse_request_path_parameters_as(GroupsPathParams, request) + update: GroupUpdate = await parse_request_body_as(GroupUpdate, request) + + group, access_rights = await _groups_api.update_standard_group( + request.app, + user_id=req_ctx.user_id, + group_id=path_params.gid, + update=update.to_model(), + ) + + updated_group = GroupGet.from_model(group, access_rights) + return envelope_json_response(updated_group) + + +@routes.delete(f"/{API_VTAG}/groups/{{gid}}", name="delete_group") +@login_required +@permission_required("groups.*") +@handle_plugin_requests_exceptions +async def delete_group(request: web.Request): + """Deletes a standard group""" + req_ctx = GroupsRequestContext.model_validate(request) + path_params = parse_request_path_parameters_as(GroupsPathParams, request) + + await _groups_api.delete_standard_group( + request.app, user_id=req_ctx.user_id, group_id=path_params.gid + ) + + return web.json_response(status=status.HTTP_204_NO_CONTENT) + + +# +# USERS in ORGANIZATION groupS (i.e. members of an organization) +# + + +@routes.get(f"/{API_VTAG}/groups/{{gid}}/users", name="get_all_group_users") +@login_required +@permission_required("groups.*") +@handle_plugin_requests_exceptions +async def get_all_group_users(request: web.Request): + """Gets users in organization groups""" + req_ctx = GroupsRequestContext.model_validate(request) + path_params = parse_request_path_parameters_as(GroupsPathParams, request) + + users_in_group = await _groups_api.list_group_members( + request.app, req_ctx.user_id, path_params.gid + ) + + return envelope_json_response( + [GroupUserGet.from_model(user) for user in users_in_group] + ) + + +@routes.post(f"/{API_VTAG}/groups/{{gid}}/users", name="add_group_user") +@login_required +@permission_required("groups.*") +@handle_plugin_requests_exceptions +async def add_group_user(request: web.Request): + """ + Adds a user in an organization group + """ + req_ctx = GroupsRequestContext.model_validate(request) + path_params = parse_request_path_parameters_as(GroupsPathParams, request) + added: GroupUserAdd = await parse_request_body_as(GroupUserAdd, request) + + await _groups_api.add_user_in_group( + request.app, + req_ctx.user_id, + path_params.gid, + new_by_user_id=added.uid, + new_by_user_name=added.user_name, + new_by_user_email=added.email, + ) + + return web.json_response(status=status.HTTP_204_NO_CONTENT) + + +@routes.get(f"/{API_VTAG}/groups/{{gid}}/users/{{uid}}", name="get_group_user") +@login_required +@permission_required("groups.*") +@handle_plugin_requests_exceptions +async def get_group_user(request: web.Request): + """ + Gets specific user in an organization group + """ + req_ctx = GroupsRequestContext.model_validate(request) + path_params = parse_request_path_parameters_as(GroupsUsersPathParams, request) + + user = await _groups_api.get_group_member( + request.app, req_ctx.user_id, path_params.gid, path_params.uid + ) + + return envelope_json_response(GroupUserGet.from_model(user)) + + +@routes.patch(f"/{API_VTAG}/groups/{{gid}}/users/{{uid}}", name="update_group_user") +@login_required +@permission_required("groups.*") +@handle_plugin_requests_exceptions +async def update_group_user(request: web.Request): + req_ctx = GroupsRequestContext.model_validate(request) + path_params = parse_request_path_parameters_as(GroupsUsersPathParams, request) + update: GroupUserUpdate = await parse_request_body_as(GroupUserUpdate, request) + + user = await _groups_api.update_group_member( + request.app, + user_id=req_ctx.user_id, + group_id=path_params.gid, + the_user_id_in_group=path_params.uid, + access_rights=update.access_rights.model_dump(mode="json"), # type: ignore[arg-type] + ) + + return envelope_json_response(GroupUserGet.from_model(user)) + + +@routes.delete(f"/{API_VTAG}/groups/{{gid}}/users/{{uid}}", name="delete_group_user") +@login_required +@permission_required("groups.*") +@handle_plugin_requests_exceptions +async def delete_group_user(request: web.Request): + req_ctx = GroupsRequestContext.model_validate(request) + path_params = parse_request_path_parameters_as(GroupsUsersPathParams, request) + await _groups_api.delete_group_member( + request.app, req_ctx.user_id, path_params.gid, path_params.uid + ) + + return web.json_response(status=status.HTTP_204_NO_CONTENT) diff --git a/services/web/server/src/simcore_service_webserver/groups/_handlers.py b/services/web/server/src/simcore_service_webserver/groups/_handlers.py deleted file mode 100644 index fac761aaf25..00000000000 --- a/services/web/server/src/simcore_service_webserver/groups/_handlers.py +++ /dev/null @@ -1,405 +0,0 @@ -import functools -import logging -from contextlib import suppress -from typing import Literal - -from aiohttp import web -from models_library.api_schemas_webserver.groups import ( - GroupCreate, - GroupGet, - GroupUpdate, - GroupUserAdd, - GroupUserGet, - GroupUserUpdate, - MyGroupsGet, -) -from models_library.users import GroupID, UserID -from pydantic import BaseModel, ConfigDict, Field, TypeAdapter -from servicelib.aiohttp import status -from servicelib.aiohttp.requests_validation import ( - parse_request_body_as, - parse_request_path_parameters_as, - parse_request_query_parameters_as, -) -from servicelib.aiohttp.typing_extension import Handler - -from .._constants import RQ_PRODUCT_KEY, RQT_USERID_KEY -from .._meta import API_VTAG -from ..login.decorators import login_required -from ..products.api import Product, get_current_product -from ..scicrunch.db import ResearchResourceRepository -from ..scicrunch.errors import InvalidRRIDError, ScicrunchError -from ..scicrunch.models import ResearchResource, ResourceHit -from ..scicrunch.service_client import SciCrunch -from ..security.decorators import permission_required -from ..users.exceptions import UserNotFoundError -from ..utils_aiohttp import envelope_json_response -from . import api -from ._classifiers import GroupClassifierRepository, build_rrids_tree_view -from .exceptions import ( - GroupNotFoundError, - UserAlreadyInGroupError, - UserInGroupNotFoundError, - UserInsufficientRightsError, -) - -_logger = logging.getLogger(__name__) - - -class _GroupsRequestContext(BaseModel): - user_id: UserID = Field(..., alias=RQT_USERID_KEY) # type: ignore[literal-required] - product_name: str = Field(..., alias=RQ_PRODUCT_KEY) # type: ignore[literal-required] - - -def _handle_groups_exceptions(handler: Handler): - @functools.wraps(handler) - async def wrapper(request: web.Request) -> web.StreamResponse: - try: - return await handler(request) - - except UserNotFoundError as exc: - raise web.HTTPNotFound( - reason=f"User {exc.uid or exc.email} not found" - ) from exc - - except GroupNotFoundError as exc: - gid = getattr(exc, "gid", "") - raise web.HTTPNotFound(reason=f"Group {gid} not found") from exc - - except UserInGroupNotFoundError as exc: - gid = getattr(exc, "gid", "") - raise web.HTTPNotFound(reason=f"User not found in group {gid}") from exc - - except UserAlreadyInGroupError as exc: - gid = getattr(exc, "gid", "") - raise web.HTTPConflict(reason=f"User is already in group {gid}") from exc - - except UserInsufficientRightsError as exc: - raise web.HTTPForbidden from exc - - return wrapper - - -routes = web.RouteTableDef() - - -@routes.get(f"/{API_VTAG}/groups", name="list_groups") -@login_required -@permission_required("groups.read") -@_handle_groups_exceptions -async def list_groups(request: web.Request): - """ - List all groups (organizations, primary, everyone and products) I belong to - """ - product: Product = get_current_product(request) - req_ctx = _GroupsRequestContext.model_validate(request) - - primary_group, user_groups, all_group = await api.list_user_groups_with_read_access( - request.app, req_ctx.user_id - ) - - my_group = { - "me": primary_group, - "organizations": user_groups, - "all": all_group, - "product": None, - } - - if product.group_id: - with suppress(GroupNotFoundError): - # Product is optional - my_group["product"] = await api.get_product_group_for_user( - app=request.app, - user_id=req_ctx.user_id, - product_gid=product.group_id, - ) - - assert MyGroupsGet.model_validate(my_group) is not None # nosec - return envelope_json_response(my_group) - - -# -# Organization groups -# - - -class _GroupPathParams(BaseModel): - gid: GroupID - model_config = ConfigDict(extra="forbid") - - -@routes.get(f"/{API_VTAG}/groups/{{gid}}", name="get_group") -@login_required -@permission_required("groups.read") -@_handle_groups_exceptions -async def get_group(request: web.Request): - """Get one group details""" - req_ctx = _GroupsRequestContext.model_validate(request) - path_params = parse_request_path_parameters_as(_GroupPathParams, request) - - group = await api.get_user_group(request.app, req_ctx.user_id, path_params.gid) - assert GroupGet.model_validate(group) is not None # nosec - return envelope_json_response(group) - - -@routes.post(f"/{API_VTAG}/groups", name="create_group") -@login_required -@permission_required("groups.*") -@_handle_groups_exceptions -async def create_group(request: web.Request): - """Creates organization groups""" - req_ctx = _GroupsRequestContext.model_validate(request) - create = await parse_request_body_as(GroupCreate, request) - new_group = create.model_dump(mode="json", exclude_unset=True) - - created_group = await api.create_user_group(request.app, req_ctx.user_id, new_group) - assert GroupGet.model_validate(created_group) is not None # nosec - return envelope_json_response(created_group, status_cls=web.HTTPCreated) - - -@routes.patch(f"/{API_VTAG}/groups/{{gid}}", name="update_group") -@login_required -@permission_required("groups.*") -@_handle_groups_exceptions -async def update_group(request: web.Request): - """Updates organization groups""" - req_ctx = _GroupsRequestContext.model_validate(request) - path_params = parse_request_path_parameters_as(_GroupPathParams, request) - update: GroupUpdate = await parse_request_body_as(GroupUpdate, request) - new_group_values = update.model_dump(exclude_unset=True) - - updated_group = await api.update_user_group( - request.app, req_ctx.user_id, path_params.gid, new_group_values - ) - assert GroupGet.model_validate(updated_group) is not None # nosec - return envelope_json_response(updated_group) - - -@routes.delete(f"/{API_VTAG}/groups/{{gid}}", name="delete_group") -@login_required -@permission_required("groups.*") -@_handle_groups_exceptions -async def delete_group(request: web.Request): - """Deletes organization groups""" - req_ctx = _GroupsRequestContext.model_validate(request) - path_params = parse_request_path_parameters_as(_GroupPathParams, request) - - await api.delete_user_group(request.app, req_ctx.user_id, path_params.gid) - return web.json_response(status=status.HTTP_204_NO_CONTENT) - - -# -# Users in organization groups (i.e. members of an organization) -# - - -@routes.get(f"/{API_VTAG}/groups/{{gid}}/users", name="get_all_group_users") -@login_required -@permission_required("groups.*") -@_handle_groups_exceptions -async def get_group_users(request: web.Request): - """Gets users in organization groups""" - req_ctx = _GroupsRequestContext.model_validate(request) - path_params = parse_request_path_parameters_as(_GroupPathParams, request) - - group_user = await api.list_users_in_group( - request.app, req_ctx.user_id, path_params.gid - ) - assert ( - TypeAdapter(list[GroupUserGet]).validate_python(group_user) is not None - ) # nosec - return envelope_json_response(group_user) - - -@routes.post(f"/{API_VTAG}/groups/{{gid}}/users", name="add_group_user") -@login_required -@permission_required("groups.*") -@_handle_groups_exceptions -async def add_group_user(request: web.Request): - """ - Adds a user in an organization group - """ - req_ctx = _GroupsRequestContext.model_validate(request) - path_params = parse_request_path_parameters_as(_GroupPathParams, request) - added: GroupUserAdd = await parse_request_body_as(GroupUserAdd, request) - - await api.add_user_in_group( - request.app, - req_ctx.user_id, - path_params.gid, - new_user_id=added.uid, - new_user_email=added.email, - ) - return web.json_response(status=status.HTTP_204_NO_CONTENT) - - -class _GroupUserPathParams(BaseModel): - gid: GroupID - uid: UserID - model_config = ConfigDict(extra="forbid") - - -@routes.get(f"/{API_VTAG}/groups/{{gid}}/users/{{uid}}", name="get_group_user") -@login_required -@permission_required("groups.*") -@_handle_groups_exceptions -async def get_group_user(request: web.Request): - """ - Gets specific user in an organization group - """ - req_ctx = _GroupsRequestContext.model_validate(request) - path_params = parse_request_path_parameters_as(_GroupUserPathParams, request) - user = await api.get_user_in_group( - request.app, req_ctx.user_id, path_params.gid, path_params.uid - ) - assert GroupUserGet.model_validate(user) is not None # nosec - return envelope_json_response(user) - - -@routes.patch(f"/{API_VTAG}/groups/{{gid}}/users/{{uid}}", name="update_group_user") -@login_required -@permission_required("groups.*") -@_handle_groups_exceptions -async def update_group_user(request: web.Request): - req_ctx = _GroupsRequestContext.model_validate(request) - path_params = parse_request_path_parameters_as(_GroupUserPathParams, request) - update: GroupUserUpdate = await parse_request_body_as(GroupUserUpdate, request) - - user = await api.update_user_in_group( - request.app, - user_id=req_ctx.user_id, - gid=path_params.gid, - the_user_id_in_group=path_params.uid, - access_rights=update.access_rights.model_dump(), - ) - assert GroupUserGet.model_validate(user) is not None # nosec - return envelope_json_response(user) - - -@routes.delete(f"/{API_VTAG}/groups/{{gid}}/users/{{uid}}", name="delete_group_user") -@login_required -@permission_required("groups.*") -@_handle_groups_exceptions -async def delete_group_user(request: web.Request): - req_ctx = _GroupsRequestContext.model_validate(request) - path_params = parse_request_path_parameters_as(_GroupUserPathParams, request) - await api.delete_user_in_group( - request.app, req_ctx.user_id, path_params.gid, path_params.uid - ) - return web.json_response(status=status.HTTP_204_NO_CONTENT) - - -# -# Classifiers -# - - -class _GroupsParams(BaseModel): - gid: GroupID - - -class _ClassifiersQuery(BaseModel): - tree_view: Literal["std"] = "std" - - -@routes.get(f"/{API_VTAG}/groups/{{gid}}/classifiers", name="get_group_classifiers") -@login_required -@permission_required("groups.*") -async def get_group_classifiers(request: web.Request): - try: - path_params = parse_request_path_parameters_as(_GroupsParams, request) - query_params: _ClassifiersQuery = parse_request_query_parameters_as( - _ClassifiersQuery, request - ) - - repo = GroupClassifierRepository(request.app) - if not await repo.group_uses_scicrunch(path_params.gid): - return await repo.get_classifiers_from_bundle(path_params.gid) - - # otherwise, build dynamic tree with RRIDs - view = await build_rrids_tree_view( - request.app, tree_view_mode=query_params.tree_view - ) - except ScicrunchError: - view = {} - - return envelope_json_response(view) - - -def _handle_scicrunch_exceptions(handler: Handler): - @functools.wraps(handler) - async def wrapper(request: web.Request) -> web.StreamResponse: - try: - return await handler(request) - - except InvalidRRIDError as err: - raise web.HTTPBadRequest(reason=f"{err}") from err - - except ScicrunchError as err: - user_msg = "Cannot get RRID since scicrunch.org service is not reachable." - _logger.exception("%s", user_msg) - raise web.HTTPServiceUnavailable(reason=user_msg) from err - - return wrapper - - -@routes.get( - f"/{API_VTAG}/groups/sparc/classifiers/scicrunch-resources/{{rrid}}", - name="get_scicrunch_resource", -) -@login_required -@permission_required("groups.*") -@_handle_scicrunch_exceptions -async def get_scicrunch_resource(request: web.Request): - rrid = request.match_info["rrid"] - rrid = SciCrunch.validate_identifier(rrid) - - # check if in database first - repo = ResearchResourceRepository(request.app) - resource: ResearchResource | None = await repo.get_resource(rrid) - if not resource: - # otherwise, request to scicrunch service - scicrunch = SciCrunch.get_instance(request.app) - resource = await scicrunch.get_resource_fields(rrid) - - return envelope_json_response(resource.model_dump()) - - -@routes.post( - f"/{API_VTAG}/groups/sparc/classifiers/scicrunch-resources/{{rrid}}", - name="add_scicrunch_resource", -) -@login_required -@permission_required("groups.*") -@_handle_scicrunch_exceptions -async def add_scicrunch_resource(request: web.Request): - rrid = request.match_info["rrid"] - - # check if exists - repo = ResearchResourceRepository(request.app) - resource: ResearchResource | None = await repo.get_resource(rrid) - if not resource: - # then request scicrunch service - scicrunch = SciCrunch.get_instance(request.app) - resource = await scicrunch.get_resource_fields(rrid) - - # insert new or if exists, then update - await repo.upsert(resource) - - return envelope_json_response(resource.model_dump()) - - -@routes.get( - f"/{API_VTAG}/groups/sparc/classifiers/scicrunch-resources:search", - name="search_scicrunch_resources", -) -@login_required -@permission_required("groups.*") -@_handle_scicrunch_exceptions -async def search_scicrunch_resources(request: web.Request): - guess_name = str(request.query["guess_name"]).strip() - - scicrunch = SciCrunch.get_instance(request.app) - hits: list[ResourceHit] = await scicrunch.search_resource(guess_name) - - return envelope_json_response([hit.model_dump() for hit in hits]) diff --git a/services/web/server/src/simcore_service_webserver/groups/_users.py b/services/web/server/src/simcore_service_webserver/groups/_users.py deleted file mode 100644 index 37b8d3453aa..00000000000 --- a/services/web/server/src/simcore_service_webserver/groups/_users.py +++ /dev/null @@ -1,22 +0,0 @@ -""" -NOTE: Coupling with user's plugin api modules should be added here to avoid cyclic dependencies -""" - -from collections.abc import Mapping -from typing import Any - -from ..utils import gravatar_hash - - -def convert_user_in_group_to_schema(user: Mapping[str, Any]) -> dict[str, str]: - - group_user = { - "id": user["id"], - "first_name": user["first_name"], - "last_name": user["last_name"], - "login": user["email"], - "gravatar_id": gravatar_hash(user["email"]), - } - group_user["accessRights"] = user["access_rights"] - group_user["gid"] = user["primary_gid"] - return group_user diff --git a/services/web/server/src/simcore_service_webserver/groups/_utils.py b/services/web/server/src/simcore_service_webserver/groups/_utils.py deleted file mode 100644 index 4f0f3ad759f..00000000000 --- a/services/web/server/src/simcore_service_webserver/groups/_utils.py +++ /dev/null @@ -1,49 +0,0 @@ -from typing import TypedDict - -from aiopg.sa.result import RowProxy - -from .exceptions import UserInsufficientRightsError - -_GROUPS_SCHEMA_TO_DB = { - "gid": "gid", - "label": "name", - "description": "description", - "thumbnail": "thumbnail", - "accessRights": "access_rights", - "inclusionRules": "inclusion_rules", -} - - -class AccessRightsDict(TypedDict): - read: bool - write: bool - delete: bool - - -def check_group_permissions( - group: RowProxy, user_id: int, gid: int, permission: str -) -> None: - if not group.access_rights[permission]: - raise UserInsufficientRightsError( - user_id=user_id, gid=gid, permission=permission - ) - - -def convert_groups_db_to_schema( - db_row: RowProxy, *, prefix: str | None = "", **kwargs -) -> dict: - converted_dict = { - k: db_row[f"{prefix}{v}"] - for k, v in _GROUPS_SCHEMA_TO_DB.items() - if f"{prefix}{v}" in db_row - } - converted_dict.update(**kwargs) - return converted_dict - - -def convert_groups_schema_to_db(schema: dict) -> dict: - return { - v: schema[k] - for k, v in _GROUPS_SCHEMA_TO_DB.items() - if k in schema and k != "gid" - } diff --git a/services/web/server/src/simcore_service_webserver/groups/api.py b/services/web/server/src/simcore_service_webserver/groups/api.py index 503eee73839..207e1ffb303 100644 --- a/services/web/server/src/simcore_service_webserver/groups/api.py +++ b/services/web/server/src/simcore_service_webserver/groups/api.py @@ -1,203 +1,23 @@ -from typing import Any - -from aiohttp import web -from aiopg.sa.result import RowProxy -from models_library.emails import LowerCaseEmailStr -from models_library.groups import Group -from models_library.users import GroupID, UserID - -from ..db.plugin import get_database_engine -from ..users.api import get_user -from . import _db -from ._utils import AccessRightsDict -from .exceptions import GroupsError - - -async def list_user_groups_with_read_access( - app: web.Application, user_id: UserID -) -> tuple[dict[str, Any], list[dict[str, Any]], dict[str, Any]]: - """ - Returns the user primary group, standard groups and the all group - """ - # NOTE: Careful! It seems we are filtering out groups, such as Product Groups, - # because they do not have read access. I believe this was done because the frontend did not want to display them. - async with get_database_engine(app).acquire() as conn: - return await _db.get_all_user_groups_with_read_access(conn, user_id=user_id) - - -async def list_all_user_groups(app: web.Application, user_id: UserID) -> list[Group]: - """ - Return all user groups - """ - async with get_database_engine(app).acquire() as conn: - groups_db = await _db.get_all_user_groups(conn, user_id=user_id) - - return [Group.model_construct(**group.model_dump()) for group in groups_db] - - -async def get_user_group( - app: web.Application, user_id: UserID, gid: GroupID -) -> dict[str, str]: - """ - Gets group gid if user associated to it and has read access - - raises GroupNotFoundError - raises UserInsufficientRightsError - """ - async with get_database_engine(app).acquire() as conn: - return await _db.get_user_group(conn, user_id=user_id, gid=gid) - - -async def get_product_group_for_user( - app: web.Application, user_id: UserID, product_gid: GroupID -) -> dict[str, str]: - """ - Returns product's group if user belongs to it, otherwise it - raises GroupNotFoundError - """ - async with get_database_engine(app).acquire() as conn: - return await _db.get_product_group_for_user( - conn, user_id=user_id, product_gid=product_gid - ) - - -async def create_user_group( - app: web.Application, user_id: UserID, new_group: dict -) -> dict[str, Any]: - async with get_database_engine(app).acquire() as conn: - return await _db.create_user_group(conn, user_id=user_id, new_group=new_group) - - -async def update_user_group( - app: web.Application, - user_id: UserID, - gid: GroupID, - new_group_values: dict[str, str], -) -> dict[str, str]: - async with get_database_engine(app).acquire() as conn: - return await _db.update_user_group( - conn, user_id=user_id, gid=gid, new_group_values=new_group_values - ) - - -async def delete_user_group( - app: web.Application, user_id: UserID, gid: GroupID -) -> None: - async with get_database_engine(app).acquire() as conn: - return await _db.delete_user_group(conn, user_id=user_id, gid=gid) - - -async def list_users_in_group( - app: web.Application, user_id: UserID, gid: GroupID -) -> list[dict[str, str]]: - async with get_database_engine(app).acquire() as conn: - return await _db.list_users_in_group(conn, user_id=user_id, gid=gid) - - -async def auto_add_user_to_groups(app: web.Application, user_id: UserID) -> None: - user: dict = await get_user(app, user_id) - - async with get_database_engine(app).acquire() as conn: - return await _db.auto_add_user_to_groups(conn, user=user) - - -async def auto_add_user_to_product_group( - app: web.Application, user_id: UserID, product_name: str -) -> GroupID: - async with get_database_engine(app).acquire() as conn: - return await _db.auto_add_user_to_product_group( - conn, user_id=user_id, product_name=product_name - ) - - -async def is_user_by_email_in_group( - app: web.Application, user_email: LowerCaseEmailStr, group_id: GroupID -) -> bool: - async with get_database_engine(app).acquire() as conn: - return await _db.is_user_by_email_in_group( - conn, - email=user_email, - group_id=group_id, - ) - - -async def add_user_in_group( - app: web.Application, - user_id: UserID, - gid: GroupID, - *, - new_user_id: UserID | None = None, - new_user_email: str | None = None, - access_rights: AccessRightsDict | None = None, -) -> None: - """Adds new_user (either by id or email) in group (with gid) owned by user_id - - Raises: - UserInGroupNotFoundError - GroupsException - """ - - if not new_user_id and not new_user_email: - msg = "Invalid method call, missing user id or user email" - raise GroupsError(msg=msg) - - async with get_database_engine(app).acquire() as conn: - if new_user_email: - user: RowProxy = await _db.get_user_from_email(conn, new_user_email) - new_user_id = user["id"] - - if not new_user_id: - msg = "Missing new user in arguments" - raise GroupsError(msg=msg) - - return await _db.add_new_user_in_group( - conn, - user_id=user_id, - gid=gid, - new_user_id=new_user_id, - access_rights=access_rights, - ) - - -async def get_user_in_group( - app: web.Application, user_id: UserID, gid: GroupID, the_user_id_in_group: int -) -> dict[str, str]: - async with get_database_engine(app).acquire() as conn: - return await _db.get_user_in_group( - conn, user_id=user_id, gid=gid, the_user_id_in_group=the_user_id_in_group - ) - - -async def update_user_in_group( - app: web.Application, - user_id: UserID, - gid: GroupID, - the_user_id_in_group: int, - access_rights: dict, -) -> dict[str, str]: - async with get_database_engine(app).acquire() as conn: - return await _db.update_user_in_group( - conn, - user_id=user_id, - gid=gid, - the_user_id_in_group=the_user_id_in_group, - access_rights=access_rights, - ) - - -async def delete_user_in_group( - app: web.Application, user_id: UserID, gid: GroupID, the_user_id_in_group: int -) -> None: - async with get_database_engine(app).acquire() as conn: - return await _db.delete_user_in_group( - conn, user_id=user_id, gid=gid, the_user_id_in_group=the_user_id_in_group - ) - - -async def get_group_from_gid(app: web.Application, gid: GroupID) -> Group | None: - async with get_database_engine(app).acquire() as conn: - group_db = await _db.get_group_from_gid(conn, gid=gid) - - if group_db: - return Group.model_construct(**group_db.model_dump()) - return None +# +# Domain-Specific Interfaces +# +from ._groups_api import ( + add_user_in_group, + auto_add_user_to_groups, + auto_add_user_to_product_group, + get_group_from_gid, + is_user_by_email_in_group, + list_all_user_groups_ids, + list_user_groups_ids_with_read_access, +) + +__all__: tuple[str, ...] = ( + "add_user_in_group", + "auto_add_user_to_groups", + "auto_add_user_to_product_group", + "get_group_from_gid", + "is_user_by_email_in_group", + "list_all_user_groups_ids", + "list_user_groups_ids_with_read_access", + # nopycln: file +) diff --git a/services/web/server/src/simcore_service_webserver/groups/models.py b/services/web/server/src/simcore_service_webserver/groups/models.py deleted file mode 100644 index bac7f2987bd..00000000000 --- a/services/web/server/src/simcore_service_webserver/groups/models.py +++ /dev/null @@ -1,6 +0,0 @@ -# mypy: disable-error-code=truthy-function -from ._utils import convert_groups_db_to_schema - -assert convert_groups_db_to_schema # nosec - -__all__: tuple[str, ...] = ("convert_groups_db_to_schema",) diff --git a/services/web/server/src/simcore_service_webserver/groups/plugin.py b/services/web/server/src/simcore_service_webserver/groups/plugin.py index 70b2f4eeb25..7000926383c 100644 --- a/services/web/server/src/simcore_service_webserver/groups/plugin.py +++ b/services/web/server/src/simcore_service_webserver/groups/plugin.py @@ -5,7 +5,7 @@ from .._constants import APP_SETTINGS_KEY from ..products.plugin import setup_products -from . import _handlers +from . import _classifiers_handlers, _groups_handlers _logger = logging.getLogger(__name__) @@ -23,4 +23,5 @@ def setup_groups(app: web.Application): # plugin dependencies setup_products(app) - app.router.add_routes(_handlers.routes) + app.router.add_routes(_groups_handlers.routes) + app.router.add_routes(_classifiers_handlers.routes) diff --git a/services/web/server/src/simcore_service_webserver/projects/_nodes_handlers.py b/services/web/server/src/simcore_service_webserver/projects/_nodes_handlers.py index b1088b67873..d5978f794d2 100644 --- a/services/web/server/src/simcore_service_webserver/projects/_nodes_handlers.py +++ b/services/web/server/src/simcore_service_webserver/projects/_nodes_handlers.py @@ -65,7 +65,7 @@ from ..catalog import client as catalog_client from ..director_v2 import api as director_v2_api from ..dynamic_scheduler import api as dynamic_scheduler_api -from ..groups.api import get_group_from_gid, list_all_user_groups +from ..groups.api import get_group_from_gid, list_all_user_groups_ids from ..groups.exceptions import GroupNotFoundError from ..login.decorators import login_required from ..projects.api import has_user_project_access_rights @@ -559,7 +559,7 @@ async def get_project_services_access_for_gid( # Get the group from the provided group ID _sharing_with_group: Group | None = await get_group_from_gid( - app=request.app, gid=query_params.for_gid + app=request.app, group_id=query_params.for_gid ) # Check if the group exists @@ -571,8 +571,10 @@ async def get_project_services_access_for_gid( _user_id = await get_user_id_from_gid( app=request.app, primary_gid=query_params.for_gid ) - _user_groups = await list_all_user_groups(app=request.app, user_id=_user_id) - groups_to_compare.update({group.gid for group in _user_groups}) + user_groups_ids = await list_all_user_groups_ids( + app=request.app, user_id=_user_id + ) + groups_to_compare.update(set(user_groups_ids)) groups_to_compare.add(query_params.for_gid) elif _sharing_with_group.group_type == GroupTypeInModel.STANDARD: groups_to_compare = {query_params.for_gid} diff --git a/services/web/server/src/simcore_service_webserver/socketio/_handlers.py b/services/web/server/src/simcore_service_webserver/socketio/_handlers.py index 356e2cc1ba7..078c22e8cf7 100644 --- a/services/web/server/src/simcore_service_webserver/socketio/_handlers.py +++ b/services/web/server/src/simcore_service_webserver/socketio/_handlers.py @@ -17,7 +17,7 @@ from servicelib.logging_utils import get_log_record_extra, log_context from servicelib.request_keys import RQT_USERID_KEY -from ..groups.api import list_user_groups_with_read_access +from ..groups.api import list_user_groups_ids_with_read_access from ..login.decorators import login_required from ..products.api import Product, get_current_product from ..resource_manager.user_sessions import managed_resource @@ -89,15 +89,13 @@ async def _set_user_in_group_rooms( app: web.Application, user_id: UserID, socket_id: SocketID ) -> None: """Adds user in rooms associated to its groups""" - primary_group, user_groups, all_group = await list_user_groups_with_read_access( - app, user_id - ) - groups = [primary_group] + user_groups + ([all_group] if bool(all_group) else []) + + group_ids = await list_user_groups_ids_with_read_access(app, user_id=user_id) sio = get_socket_server(app) - for group in groups: + for gid in group_ids: # NOTE socketio need to be upgraded that's why enter_room is not an awaitable - sio.enter_room(socket_id, SocketIORoomStr.from_group_id(group["gid"])) + sio.enter_room(socket_id, SocketIORoomStr.from_group_id(gid)) sio.enter_room(socket_id, SocketIORoomStr.from_user_id(user_id)) diff --git a/services/web/server/src/simcore_service_webserver/tree.md b/services/web/server/src/simcore_service_webserver/tree.md new file mode 100644 index 00000000000..0117a6c851e --- /dev/null +++ b/services/web/server/src/simcore_service_webserver/tree.md @@ -0,0 +1,562 @@ +This is a tree view of my app. It is built in python's aiohttp. + + +├── activity +│   ├── _api.py +│   ├── _handlers.py +│   ├── plugin.py +│   └── settings.py +├── announcements +│   ├── _api.py +│   ├── _handlers.py +│   ├── _models.py +│   ├── plugin.py +│   └── _redis.py +├── api_keys +│   ├── api.py +│   ├── errors.py +│   ├── _exceptions_handlers.py +│   ├── _models.py +│   ├── plugin.py +│   ├── _repository.py +│   ├── _rest.py +│   ├── _rpc.py +│   └── _service.py +├── application.py +├── application_settings.py +├── application_settings_utils.py +├── catalog +│   ├── _api.py +│   ├── _api_units.py +│   ├── client.py +│   ├── _constants.py +│   ├── exceptions.py +│   ├── _handlers_errors.py +│   ├── _handlers.py +│   ├── licenses +│   │   ├── api.py +│   │   ├── errors.py +│   │   ├── _exceptions_handlers.py +│   │   ├── _licensed_items_api.py +│   │   ├── _licensed_items_db.py +│   │   ├── _licensed_items_handlers.py +│   │   ├── _models.py +│   │   └── plugin.py +│   ├── _models.py +│   ├── plugin.py +│   ├── settings.py +│   └── _tags_handlers.py +├── cli.py +├── _constants.py +├── db +│   ├── _aiopg.py +│   ├── _asyncpg.py +│   ├── base_repository.py +│   ├── models.py +│   ├── plugin.py +│   └── settings.py +├── db_listener +│   ├── _db_comp_tasks_listening_task.py +│   ├── plugin.py +│   └── _utils.py +├── diagnostics +│   ├── _handlers.py +│   ├── _healthcheck.py +│   ├── _monitoring.py +│   ├── plugin.py +│   └── settings.py +├── director_v2 +│   ├── _abc.py +│   ├── api.py +│   ├── _api_utils.py +│   ├── _core_base.py +│   ├── _core_computations.py +│   ├── _core_dynamic_services.py +│   ├── _core_utils.py +│   ├── exceptions.py +│   ├── _handlers.py +│   ├── plugin.py +│   └── settings.py +├── dynamic_scheduler +│   ├── api.py +│   ├── plugin.py +│   └── settings.py +├── email +│   ├── _core.py +│   ├── _handlers.py +│   ├── plugin.py +│   ├── settings.py +│   └── utils.py +├── errors.py +├── exception_handling +│   ├── _base.py +│   └── _factory.py +├── exporter +│   ├── exceptions.py +│   ├── _formatter +│   │   ├── archive.py +│   │   ├── _sds.py +│   │   ├── template_json.py +│   │   └── xlsx +│   │   ├── code_description.py +│   │   ├── core +│   │   │   ├── styling_components.py +│   │   │   └── xlsx_base.py +│   │   ├── dataset_description.py +│   │   ├── manifest.py +│   │   ├── utils.py +│   │   └── writer.py +│   ├── _handlers.py +│   ├── plugin.py +│   ├── settings.py +│   └── utils.py +├── folders +│   ├── api.py +│   ├── errors.py +│   ├── _exceptions_handlers.py +│   ├── _folders_api.py +│   ├── _folders_db.py +│   ├── _folders_handlers.py +│   ├── _models.py +│   ├── plugin.py +│   ├── _trash_api.py +│   ├── _trash_handlers.py +│   ├── _workspaces_api.py +│   └── _workspaces_handlers.py +├── garbage_collector +│   ├── _core_disconnected.py +│   ├── _core_guests.py +│   ├── _core_orphans.py +│   ├── _core.py +│   ├── _core_utils.py +│   ├── plugin.py +│   ├── settings.py +│   ├── _tasks_api_keys.py +│   ├── _tasks_core.py +│   ├── _tasks_trash.py +│   └── _tasks_users.py +├── groups +│   ├── api.py +│   ├── _classifiers_api.py +│   ├── _classifiers_handlers.py +│   ├── _common +│   │   ├── exceptions_handlers.py +│   │   └── schemas.py +│   ├── exceptions.py +│   ├── _groups_api.py +│   ├── _groups_db.py +│   ├── _groups_handlers.py +│   └── plugin.py +├── invitations +│   ├── api.py +│   ├── _client.py +│   ├── _core.py +│   ├── errors.py +│   ├── plugin.py +│   └── settings.py +├── login +│   ├── _2fa_api.py +│   ├── _2fa_handlers.py +│   ├── _auth_api.py +│   ├── _auth_handlers.py +│   ├── cli.py +│   ├── _confirmation.py +│   ├── _constants.py +│   ├── decorators.py +│   ├── errors.py +│   ├── handlers_change.py +│   ├── handlers_confirmation.py +│   ├── handlers_registration.py +│   ├── _models.py +│   ├── plugin.py +│   ├── _registration_api.py +│   ├── _registration_handlers.py +│   ├── _registration.py +│   ├── _security.py +│   ├── settings.py +│   ├── _sql.py +│   ├── storage.py +│   ├── utils_email.py +│   └── utils.py +├── log.py +├── long_running_tasks.py +├── __main__.py +├── meta_modeling +│   ├── _function_nodes.py +│   ├── _handlers.py +│   ├── _iterations.py +│   ├── plugin.py +│   ├── _projects.py +│   ├── _results.py +│   └── _version_control.py +├── _meta.py +├── models.py +├── notifications +│   ├── plugin.py +│   ├── project_logs.py +│   ├── _rabbitmq_consumers_common.py +│   ├── _rabbitmq_exclusive_queue_consumers.py +│   ├── _rabbitmq_nonexclusive_queue_consumers.py +│   └── wallet_osparc_credits.py +├── payments +│   ├── api.py +│   ├── _autorecharge_api.py +│   ├── _autorecharge_db.py +│   ├── errors.py +│   ├── _events.py +│   ├── _methods_api.py +│   ├── _methods_db.py +│   ├── _onetime_api.py +│   ├── _onetime_db.py +│   ├── plugin.py +│   ├── _rpc_invoice.py +│   ├── _rpc.py +│   ├── settings.py +│   ├── _socketio.py +│   └── _tasks.py +├── products +│   ├── _api.py +│   ├── api.py +│   ├── _db.py +│   ├── errors.py +│   ├── _events.py +│   ├── _handlers.py +│   ├── _invitations_handlers.py +│   ├── _middlewares.py +│   ├── _model.py +│   ├── plugin.py +│   └── _rpc.py +├── projects +│   ├── _access_rights_api.py +│   ├── _access_rights_db.py +│   ├── api.py +│   ├── _comments_api.py +│   ├── _comments_db.py +│   ├── _comments_handlers.py +│   ├── _common_models.py +│   ├── _crud_api_create.py +│   ├── _crud_api_delete.py +│   ├── _crud_api_read.py +│   ├── _crud_handlers_models.py +│   ├── _crud_handlers.py +│   ├── db.py +│   ├── _db_utils.py +│   ├── exceptions.py +│   ├── _folders_api.py +│   ├── _folders_db.py +│   ├── _folders_handlers.py +│   ├── _groups_api.py +│   ├── _groups_db.py +│   ├── _groups_handlers.py +│   ├── lock.py +│   ├── _metadata_api.py +│   ├── _metadata_db.py +│   ├── _metadata_handlers.py +│   ├── models.py +│   ├── _nodes_api.py +│   ├── _nodes_handlers.py +│   ├── _nodes_utils.py +│   ├── nodes_utils.py +│   ├── _observer.py +│   ├── _permalink_api.py +│   ├── plugin.py +│   ├── _ports_api.py +│   ├── _ports_handlers.py +│   ├── _projects_access.py +│   ├── projects_api.py +│   ├── _projects_db.py +│   ├── _projects_nodes_pricing_unit_handlers.py +│   ├── settings.py +│   ├── _states_handlers.py +│   ├── _tags_api.py +│   ├── _tags_handlers.py +│   ├── _trash_api.py +│   ├── _trash_handlers.py +│   ├── utils.py +│   ├── _wallets_api.py +│   ├── _wallets_handlers.py +│   ├── _workspaces_api.py +│   └── _workspaces_handlers.py +├── publications +│   ├── _handlers.py +│   └── plugin.py +├── rabbitmq.py +├── rabbitmq_settings.py +├── redis.py +├── resource_manager +│   ├── _constants.py +│   ├── plugin.py +│   ├── registry.py +│   ├── settings.py +│   └── user_sessions.py +├── _resources.py +├── resource_usage +│   ├── api.py +│   ├── _client.py +│   ├── _constants.py +│   ├── errors.py +│   ├── _observer.py +│   ├── plugin.pyf +│   ├── _pricing_plans_admin_api.py +│   ├── _pricing_plans_admin_handlers.py +│   ├── _pricing_plans_api.py +│   ├── _pricing_plans_handlers.py +│   ├── _service_runs_api.py +│   ├── _service_runs_handlers.py +│   ├── settings.py +│   └── _utils.py +├── rest +│   ├── _handlers.py +│   ├── healthcheck.py +│   ├── plugin.py +│   ├── settings.py +│   └── _utils.py +├── scicrunch +│   ├── db.py +│   ├── errors.py +│   ├── models.py +│   ├── plugin.py +│   ├── _resolver.py +│   ├── _rest.py +│   ├── service_client.py +│   └── settings.py +├── security +│   ├── api.py +│   ├── _authz_access_model.py +│   ├── _authz_access_roles.py +│   ├── _authz_db.py +│   ├── _authz_policy.py +│   ├── _constants.py +│   ├── decorators.py +│   ├── _identity_api.py +│   ├── _identity_policy.py +│   └── plugin.py +├── session +│   ├── access_policies.py +│   ├── api.py +│   ├── _cookie_storage.py +│   ├── errors.py +│   ├── plugin.py +│   └── settings.py +├── socketio +│   ├── _handlers.py +│   ├── messages.py +│   ├── models.py +│   ├── _observer.py +│   ├── plugin.py +│   ├── server.py +│   └── _utils.py +├── statics +│   ├── _constants.py +│   ├── _events.py +│   ├── _handlers.py +│   ├── plugin.py +│   └── settings.py +├── storage +│   ├── api.py +│   ├── _handlers.py +│   ├── plugin.py +│   ├── schemas.py +│   └── settings.py +├── studies_dispatcher +│   ├── _catalog.py +│   ├── _constants.py +│   ├── _core.py +│   ├── _errors.py +│   ├── _models.py +│   ├── plugin.py +│   ├── _projects_permalinks.py +│   ├── _projects.py +│   ├── _redirects_handlers.py +│   ├── _rest_handlers.py +│   ├── settings.py +│   ├── _studies_access.py +│   └── _users.py +├── tags +│   ├── _api.py +│   ├── _handlers.py +│   ├── plugin.py +│   └── schemas.py +├── tracing.py +├── users +│   ├── _api.py +│   ├── api.py +│   ├── _constants.py +│   ├── _db.py +│   ├── exceptions.py +│   ├── _handlers.py +│   ├── _models.py +│   ├── _notifications_handlers.py +│   ├── _notifications.py +│   ├── plugin.py +│   ├── _preferences_api.py +│   ├── preferences_api.py +│   ├── _preferences_db.py +│   ├── _preferences_handlers.py +│   ├── _preferences_models.py +│   ├── _schemas.py +│   ├── schemas.py +│   ├── settings.py +│   ├── _tokens_handlers.py +│   └── _tokens.py +├── utils_aiohttp.py +├── utils.py +├── utils_rate_limiting.py +├── version_control +│   ├── _core.py +│   ├── db.py +│   ├── errors.py +│   ├── _handlers_base.py +│   ├── _handlers.py +│   ├── models.py +│   ├── plugin.py +│   ├── vc_changes.py +│   └── vc_tags.py +├── wallets +│   ├── _api.py +│   ├── api.py +│   ├── _constants.py +│   ├── _db.py +│   ├── errors.py +│   ├── _events.py +│   ├── _groups_api.py +│   ├── _groups_db.py +│   ├── _groups_handlers.py +│   ├── _handlers.py +│   ├── _payments_handlers.py +│   └── plugin.py +└── workspaces + ├── api.py + ├── errors.py + ├── _exceptions_handlers.py + ├── _groups_api.py + ├── _groups_db.py + ├── _groups_handlers.py + ├── _models.py + ├── plugin.py + ├── _trash_api.py + ├── _trash_handlers.py + ├── _workspaces_api.py + ├── _workspaces_db.py + └── _workspaces_handlers.py + + + + + +The top folders represent plugins that could be interprested as different domains with small compling between each other + +Here are some conventions + +- `plugin` has a setup function to setup the app (e.g. add routes, setup events etc ). Classic `setup_xxx(app)` for aiohttp +- `settings` includes pydantic settings classes specific to the domain +- `exceptions` or `errors` include only exceptions classes + - `_exceptions_handlers` are utils to handle exceptions +- `models` correspond to domain models, i.e. not part of any of the controllers interfaces. Those are denoted `scheme`. + +Then + +- `_handlers` (or _rest) represent the rest handlers (i.e. controller layer) +- `_rpc` contains handlers (server side) to an RPC interface (i.e. controller layer) +- `_api` (or `_service`) represent the business logic of this domain (i.e. service layer) + - the shared service layer for inter-domain logic is called `api` (i.e. without `_`) +- `_db` (or `_repository`) represents the repository layer + + +Could you please apply the rules on this structure and come up with a new tree that follows: +- keeps the domain-drive modular organization +- every domain implements controller-service-repository (CSR) +- highligh the shared service layer for inter-domain logic + + +As an output just recreate the tree adding some comments on it (e.g. with #) but no further explanatio is needed + + + +```plaintext +├── activity +│   ├── controllers +│   │   ├── rest_handlers.py # Rest API handlers (controller layer) +│   │   ├── rpc_handlers.py # RPC handlers (controller layer) +│   ├── services +│   │   ├── domain_service.py # Business logic for activity domain (service layer) +│   ├── repositories +│   │   ├── activity_repository.py # Data access logic (repository layer) +│   ├── plugin.py # Setup function for activity domain +│   ├── settings.py # Domain-specific settings +│   ├── exceptions.py # Domain-specific exceptions +├── announcements +│   ├── controllers +│   │   ├── rest_handlers.py # Rest API handlers (controller layer) +│   │   ├── rpc_handlers.py # RPC handlers (controller layer) +│   ├── services +│   │   ├── domain_service.py # Business logic for announcements domain (service layer) +│   ├── repositories +│   │   ├── announcements_repository.py # Data access logic (repository layer) +│   ├── plugin.py # Setup function for announcements domain +│   ├── settings.py # Domain-specific settings +│   ├── exceptions.py # Domain-specific exceptions +├── api_keys +│   ├── controllers +│   │   ├── rest_handlers.py # Rest API handlers (controller layer) +│   │   ├── rpc_handlers.py # RPC handlers (controller layer) +│   ├── services +│   │   ├── domain_service.py # Business logic for api_keys domain (service layer) +│   ├── repositories +│   │   ├── api_keys_repository.py # Data access logic (repository layer) +│   ├── plugin.py # Setup function for api_keys domain +│   ├── settings.py # Domain-specific settings +│   ├── exceptions.py # Domain-specific exceptions +├── shared_services +│   ├── inter_domain_service.py # Shared service layer for inter-domain logic +├── db +│   ├── connection.py # Database connection logic +│   ├── base_repository.py # Base repository logic +│   ├── plugin.py # Setup function for database +│   ├── settings.py # Database-specific settings +├── catalog +│   ├── controllers +│   │   ├── rest_handlers.py # Rest API handlers (controller layer) +│   │   ├── rpc_handlers.py # RPC handlers (controller layer) +│   ├── services +│   │   ├── domain_service.py # Business logic for catalog domain (service layer) +│   ├── repositories +│   │   ├── catalog_repository.py # Data access logic (repository layer) +│   ├── plugin.py # Setup function for catalog domain +│   ├── settings.py # Domain-specific settings +│   ├── exceptions.py # Domain-specific exceptions +├── users +│   ├── controllers +│   │   ├── rest_handlers.py # Rest API handlers (controller layer) +│   │   ├── rpc_handlers.py # RPC handlers (controller layer) +│   ├── services +│   │   ├── domain_service.py # Business logic for users domain (service layer) +│   ├── repositories +│   │   ├── users_repository.py # Data access logic (repository layer) +│   ├── plugin.py # Setup function for users domain +│   ├── settings.py # Domain-specific settings +│   ├── exceptions.py # Domain-specific exceptions +├── projects +│   ├── controllers +│   │   ├── rest_handlers.py # Rest API handlers (controller layer) +│   │   ├── rpc_handlers.py # RPC handlers (controller layer) +│   ├── services +│   │   ├── domain_service.py # Business logic for projects domain (service layer) +│   ├── repositories +│   │   ├── projects_repository.py # Data access logic (repository layer) +│   ├── plugin.py # Setup function for projects domain +│   ├── settings.py # Domain-specific settings +│   ├── exceptions.py # Domain-specific exceptions +├── shared +│   ├── models +│   │   ├── user.py # Shared user model +│   │   ├── project.py # Shared project model +│   ├── schemas +│   │   ├── user_schema.py # Shared user schemas +│   │   ├── project_schema.py # Shared project schemas +│   ├── utils +│   │   ├── logger.py # Shared logging logic +│   │   ├── validators.py # Shared validation logic +├── application.py # Main application initialization +└── cli.py # Command-line interface logic +``` diff --git a/services/web/server/src/simcore_service_webserver/users/_handlers.py b/services/web/server/src/simcore_service_webserver/users/_handlers.py index d67d772e0ee..25785673a03 100644 --- a/services/web/server/src/simcore_service_webserver/users/_handlers.py +++ b/services/web/server/src/simcore_service_webserver/users/_handlers.py @@ -2,7 +2,7 @@ import logging from aiohttp import web -from models_library.api_schemas_webserver.users import ProfileGet, ProfileUpdate +from models_library.api_schemas_webserver.users import MyProfileGet, MyProfilePatch from models_library.users import UserID from pydantic import BaseModel, Field from servicelib.aiohttp import status @@ -74,7 +74,7 @@ async def wrapper(request: web.Request) -> web.StreamResponse: @_handle_users_exceptions async def get_my_profile(request: web.Request) -> web.Response: req_ctx = UsersRequestContext.model_validate(request) - profile: ProfileGet = await api.get_user_profile( + profile: MyProfileGet = await api.get_user_profile( request.app, req_ctx.user_id, req_ctx.product_name ) return envelope_json_response(profile) @@ -89,7 +89,7 @@ async def get_my_profile(request: web.Request) -> web.Response: @_handle_users_exceptions async def update_my_profile(request: web.Request) -> web.Response: req_ctx = UsersRequestContext.model_validate(request) - profile_update = await parse_request_body_as(ProfileUpdate, request) + profile_update = await parse_request_body_as(MyProfilePatch, request) await api.update_user_profile( request.app, user_id=req_ctx.user_id, update=profile_update diff --git a/services/web/server/src/simcore_service_webserver/users/api.py b/services/web/server/src/simcore_service_webserver/users/api.py index 7fc2c138204..623d4f44396 100644 --- a/services/web/server/src/simcore_service_webserver/users/api.py +++ b/services/web/server/src/simcore_service_webserver/users/api.py @@ -15,9 +15,9 @@ from aiopg.sa.engine import Engine from aiopg.sa.result import RowProxy from models_library.api_schemas_webserver.users import ( - ProfileGet, - ProfilePrivacyGet, - ProfileUpdate, + MyProfileGet, + MyProfilePatch, + MyProfilePrivacyGet, ) from models_library.basic_types import IDStr from models_library.products import ProductName @@ -28,9 +28,9 @@ from simcore_postgres_database.utils_groups_extra_properties import ( GroupExtraPropertiesNotFoundError, ) +from simcore_postgres_database.utils_users import generate_alternative_username from ..db.plugin import get_database_engine -from ..groups.models import convert_groups_db_to_schema from ..login.storage import AsyncpgStorage, get_plugin_storage from ..security.api import clean_auth_policy_cache from . import _db @@ -46,6 +46,29 @@ _logger = logging.getLogger(__name__) +_GROUPS_SCHEMA_TO_DB = { + "gid": "gid", + "label": "name", + "description": "description", + "thumbnail": "thumbnail", + "accessRights": "access_rights", +} + + +def _convert_groups_db_to_schema( + db_row: RowProxy, *, prefix: str | None = "", **kwargs +) -> dict: + # NOTE: Deprecated. has to be replaced with + converted_dict = { + k: db_row[f"{prefix}{v}"] + for k, v in _GROUPS_SCHEMA_TO_DB.items() + if f"{prefix}{v}" in db_row + } + converted_dict.update(**kwargs) + converted_dict["inclusionRules"] = {} + return converted_dict + + def _parse_as_user(user_id: Any) -> UserID: try: return TypeAdapter(UserID).validate_python(user_id) @@ -55,7 +78,7 @@ def _parse_as_user(user_id: Any) -> UserID: async def get_user_profile( app: web.Application, user_id: UserID, product_name: ProductName -) -> ProfileGet: +) -> MyProfileGet: """ :raises UserNotFoundError: :raises MissingGroupExtraPropertiesForProductError: when product is not properly configured @@ -63,7 +86,7 @@ async def get_user_profile( engine = get_database_engine(app) user_profile: dict[str, Any] = {} - user_primary_group = all_group = {} + user_primary_group = everyone_group = {} user_standard_groups = [] user_id = _parse_as_user(user_id) @@ -98,20 +121,20 @@ async def get_user_profile( assert user_profile["id"] == user_id # nosec if row.groups_type == GroupType.EVERYONE: - all_group = convert_groups_db_to_schema( + everyone_group = _convert_groups_db_to_schema( row, prefix="groups_", accessRights=row["user_to_groups_access_rights"], ) elif row.groups_type == GroupType.PRIMARY: - user_primary_group = convert_groups_db_to_schema( + user_primary_group = _convert_groups_db_to_schema( row, prefix="groups_", accessRights=row["user_to_groups_access_rights"], ) else: user_standard_groups.append( - convert_groups_db_to_schema( + _convert_groups_db_to_schema( row, prefix="groups_", accessRights=row["user_to_groups_access_rights"], @@ -136,7 +159,7 @@ async def get_user_profile( if user_profile.get("expiration_date"): optional["expiration_date"] = user_profile["expiration_date"] - return ProfileGet( + return MyProfileGet( id=user_profile["id"], user_name=user_profile["user_name"], first_name=user_profile["first_name"], @@ -146,9 +169,9 @@ async def get_user_profile( groups={ # type: ignore[arg-type] "me": user_primary_group, "organizations": user_standard_groups, - "all": all_group, + "all": everyone_group, }, - privacy=ProfilePrivacyGet( + privacy=MyProfilePrivacyGet( hide_fullname=user_profile["privacy_hide_fullname"], hide_email=user_profile["privacy_hide_email"], ), @@ -161,7 +184,7 @@ async def update_user_profile( app: web.Application, *, user_id: UserID, - update: ProfileUpdate, + update: MyProfilePatch, ) -> None: """ Raises: @@ -180,8 +203,13 @@ async def update_user_profile( assert resp.rowcount == 1 # nosec except db_errors.UniqueViolation as err: + user_name = updated_values.get("name") + raise UserNameDuplicateError( - user_name=updated_values.get("name") + user_name=user_name, + alternative_user_name=generate_alternative_username(user_name), + user_id=user_id, + updated_values=updated_values, ) from err diff --git a/services/web/server/src/simcore_service_webserver/users/exceptions.py b/services/web/server/src/simcore_service_webserver/users/exceptions.py index 653cfeca719..d1f838d2133 100644 --- a/services/web/server/src/simcore_service_webserver/users/exceptions.py +++ b/services/web/server/src/simcore_service_webserver/users/exceptions.py @@ -22,7 +22,10 @@ def __init__(self, *, uid: int | None = None, email: str | None = None, **ctx: A class UserNameDuplicateError(UsersBaseError): - msg_template = "Username {user_name} is already in use. Violates unique constraint" + msg_template = ( + "The username '{user_name}' is already taken. " + "Consider using '{alternative_user_name}' instead." + ) class TokenNotFoundError(UsersBaseError): diff --git a/services/web/server/tests/conftest.py b/services/web/server/tests/conftest.py index f215368ad1d..0e1de456b78 100644 --- a/services/web/server/tests/conftest.py +++ b/services/web/server/tests/conftest.py @@ -64,6 +64,7 @@ "pytest_simcore.environment_configs", "pytest_simcore.faker_users_data", "pytest_simcore.hypothesis_type_strategies", + "pytest_simcore.openapi_specs", "pytest_simcore.postgres_service", "pytest_simcore.pydantic_models", "pytest_simcore.pytest_global_environs", @@ -74,8 +75,8 @@ "pytest_simcore.services_api_mocks_for_aiohttp_clients", "pytest_simcore.simcore_service_library_fixtures", "pytest_simcore.simcore_services", + "pytest_simcore.simcore_webserver_groups_fixtures", "pytest_simcore.socketio_client", - "pytest_simcore.openapi_specs", ] diff --git a/services/web/server/tests/integration/01/test_garbage_collection.py b/services/web/server/tests/integration/01/test_garbage_collection.py index 9c5c133f378..f373c302df4 100644 --- a/services/web/server/tests/integration/01/test_garbage_collection.py +++ b/services/web/server/tests/integration/01/test_garbage_collection.py @@ -21,6 +21,7 @@ from aiohttp import web from aiohttp.test_utils import TestClient from aioresponses import aioresponses +from models_library.groups import EVERYONE_GROUP_ID, StandardGroupCreate from models_library.projects_state import RunningState from pytest_mock import MockerFixture from pytest_simcore.helpers.webserver_login import UserInfoDict, log_client_in @@ -35,11 +36,8 @@ from simcore_service_webserver.director_v2.plugin import setup_director_v2 from simcore_service_webserver.garbage_collector import _core as gc_core from simcore_service_webserver.garbage_collector.plugin import setup_garbage_collector -from simcore_service_webserver.groups.api import ( - add_user_in_group, - create_user_group, - list_user_groups_with_read_access, -) +from simcore_service_webserver.groups._groups_api import create_standard_group +from simcore_service_webserver.groups.api import add_user_in_group from simcore_service_webserver.login.plugin import setup_login from simcore_service_webserver.projects._crud_api_delete import get_scheduled_tasks from simcore_service_webserver.projects._groups_db import update_or_insert_project_group @@ -261,13 +259,12 @@ async def get_template_project( ): """returns a tempalte shared with all""" assert client.app - _, _, all_group = await list_user_groups_with_read_access(client.app, user["id"]) # the information comes from a file, randomize it project_data["name"] = f"Fake template {uuid4()}" project_data["uuid"] = f"{uuid4()}" project_data["accessRights"] = { - str(all_group["gid"]): {"read": True, "write": False, "delete": False} + str(EVERYONE_GROUP_ID): {"read": True, "write": False, "delete": False} } if access_rights is not None: project_data["accessRights"].update(access_rights) @@ -281,22 +278,33 @@ async def get_template_project( ) -async def get_group(client: TestClient, user): +async def get_group(client: TestClient, user: dict): """Creates a group for a given user""" - return await create_user_group( + assert client.app + + group, _ = await create_standard_group( app=client.app, user_id=user["id"], - new_group={"label": uuid4(), "description": uuid4(), "thumbnail": None}, + create=StandardGroupCreate.model_validate( + { + "name": f"name-{uuid4()}", + "description": f"desc-{uuid4()}", + "thumbnail": None, + } + ), ) + return group.model_dump(mode="json") async def invite_user_to_group(client: TestClient, owner, invitee, group): """Invite a user to a group on which the owner has writes over""" + assert client.app + await add_user_in_group( client.app, owner["id"], group["gid"], - new_user_id=invitee["id"], + new_by_user_id=invitee["id"], ) diff --git a/services/web/server/tests/integration/conftest.py b/services/web/server/tests/integration/conftest.py index 0dee770f2f2..2f8cda8aa5e 100644 --- a/services/web/server/tests/integration/conftest.py +++ b/services/web/server/tests/integration/conftest.py @@ -15,7 +15,6 @@ import json import logging import sys -from collections.abc import AsyncIterable from copy import deepcopy from pathlib import Path from string import Template @@ -27,13 +26,6 @@ from pytest_simcore.helpers import FIXTURE_CONFIG_CORE_SERVICES_SELECTION from pytest_simcore.helpers.dict_tools import ConfigDict from pytest_simcore.helpers.docker import get_service_published_port -from pytest_simcore.helpers.webserver_login import NewUser, UserInfoDict -from simcore_service_webserver.groups.api import ( - add_user_in_group, - create_user_group, - delete_user_group, - list_user_groups_with_read_access, -) CURRENT_DIR = Path(sys.argv[0] if __name__ == "__main__" else __file__).resolve().parent @@ -177,72 +169,6 @@ def mock_orphaned_services(mocker: MockerFixture) -> mock.Mock: ) -@pytest.fixture -async def primary_group(client, logged_user: UserInfoDict) -> dict[str, str]: - primary_group, _, _ = await list_user_groups_with_read_access( - client.app, logged_user["id"] - ) - return primary_group - - -@pytest.fixture -async def standard_groups( - client, logged_user: UserInfoDict -) -> AsyncIterable[list[dict[str, str]]]: - # create a separate admin account to create some standard groups for the logged user - sparc_group = { - "gid": "5", # this will be replaced - "label": "SPARC", - "description": "Stimulating Peripheral Activity to Relieve Conditions", - "thumbnail": "https://commonfund.nih.gov/sites/default/files/sparc-image-homepage500px.png", - } - team_black_group = { - "gid": "5", # this will be replaced - "label": "team Black", - "description": "THE incredible black team", - "thumbnail": None, - } - async with NewUser( - {"name": f"{logged_user['name']}_admin", "role": "USER"}, client.app - ) as admin_user: - sparc_group = await create_user_group(client.app, admin_user["id"], sparc_group) - team_black_group = await create_user_group( - client.app, admin_user["id"], team_black_group - ) - await add_user_in_group( - client.app, - admin_user["id"], - int(sparc_group["gid"]), - new_user_id=logged_user["id"], - ) - await add_user_in_group( - client.app, - admin_user["id"], - int(team_black_group["gid"]), - new_user_email=logged_user["email"], - ) - - _, standard_groups, _ = await list_user_groups_with_read_access( - client.app, logged_user["id"] - ) - - yield standard_groups - - # clean groups - await delete_user_group(client.app, admin_user["id"], int(sparc_group["gid"])) - await delete_user_group( - client.app, admin_user["id"], int(team_black_group["gid"]) - ) - - -@pytest.fixture -async def all_group(client, logged_user) -> dict[str, str]: - _, _, all_group = await list_user_groups_with_read_access( - client.app, logged_user["id"] - ) - return all_group - - @pytest.fixture(scope="session") def osparc_product_name() -> str: return "osparc" diff --git a/services/web/server/tests/unit/isolated/test_groups_models.py b/services/web/server/tests/unit/isolated/test_groups_models.py index d51b467c015..9813ca6009c 100644 --- a/services/web/server/tests/unit/isolated/test_groups_models.py +++ b/services/web/server/tests/unit/isolated/test_groups_models.py @@ -1,7 +1,25 @@ import models_library.groups +import pytest import simcore_postgres_database.models.groups -from models_library.api_schemas_webserver.groups import GroupGet +from faker import Faker +from models_library.api_schemas_webserver._base import OutputSchema +from models_library.api_schemas_webserver.groups import ( + GroupCreate, + GroupGet, + GroupUpdate, + GroupUserAdd, + GroupUserGet, +) +from models_library.groups import ( + AccessRightsDict, + Group, + GroupMember, + GroupTypeInModel, + StandardGroupCreate, + StandardGroupUpdate, +) from models_library.utils.enums import enum_to_dict +from pydantic import ValidationError def test_models_library_and_postgress_database_enums_are_equivalent(): @@ -39,3 +57,68 @@ def test_sanitize_legacy_data(): assert users_group_2.thumbnail is None assert users_group_1 == users_group_2 + + +def test_output_schemas_from_models(faker: Faker): + # output : schema <- model + assert issubclass(GroupGet, OutputSchema) + domain_model = Group( + gid=1, + name=faker.word(), + description=faker.sentence(), + group_type=GroupTypeInModel.STANDARD, + thumbnail=None, + ) + output_schema = GroupGet.from_model( + domain_model, + access_rights=AccessRightsDict(read=True, write=False, delete=False), + ) + assert output_schema.label == domain_model.name + + # output : schema <- model + domain_model = GroupMember( + id=12, + name=faker.user_name(), + email=None, + first_name=None, + last_name=None, + primary_gid=13, + access_rights=AccessRightsDict(read=True, write=False, delete=False), + ) + output_schema = GroupUserGet.from_model(user=domain_model) + assert output_schema.user_name == domain_model.name + + +def test_input_schemas_to_models(faker: Faker): + # input : scheam -> model + input_schema = GroupCreate( + label=faker.word(), description=faker.sentence(), thumbnail=faker.url() + ) + domain_model = input_schema.to_model() + assert isinstance(domain_model, StandardGroupCreate) + assert domain_model.name == input_schema.label + + # input : scheam -> model + input_schema = GroupUpdate(label=faker.word()) + domain_model = input_schema.to_model() + assert isinstance(domain_model, StandardGroupUpdate) + assert domain_model.name == input_schema.label + + +def test_group_user_add_options(faker: Faker): + def _only_one_true(*args): + return sum(bool(arg) for arg in args) == 1 + + input_schema = GroupUserAdd(uid=faker.pyint()) + assert input_schema.uid + assert _only_one_true(input_schema.uid, input_schema.user_name, input_schema.email) + + input_schema = GroupUserAdd(userName=faker.user_name()) + assert input_schema.user_name + assert _only_one_true(input_schema.uid, input_schema.user_name, input_schema.email) + + input_schema = GroupUserAdd(email=faker.email()) + assert _only_one_true(input_schema.uid, input_schema.user_name, input_schema.email) + + with pytest.raises(ValidationError): + GroupUserAdd(userName=faker.user_name(), email=faker.email()) diff --git a/services/web/server/tests/unit/isolated/test_users_models.py b/services/web/server/tests/unit/isolated/test_users_models.py index 8ff676476ee..db129b68550 100644 --- a/services/web/server/tests/unit/isolated/test_users_models.py +++ b/services/web/server/tests/unit/isolated/test_users_models.py @@ -11,9 +11,9 @@ import pytest from faker import Faker from models_library.api_schemas_webserver.users import ( - ProfileGet, - ProfilePrivacyGet, - ProfileUpdate, + MyProfileGet, + MyProfilePatch, + MyProfilePrivacyGet, ) from models_library.generics import Envelope from models_library.utils.fastapi_encoders import jsonable_encoder @@ -26,7 +26,7 @@ @pytest.mark.parametrize( "model_cls", - [ProfileGet, ThirdPartyToken], + [MyProfileGet, ThirdPartyToken], ) def test_user_models_examples( model_cls: type[BaseModel], model_cls_examples: dict[str, Any] @@ -51,23 +51,23 @@ def test_user_models_examples( @pytest.fixture -def fake_profile_get(faker: Faker) -> ProfileGet: +def fake_profile_get(faker: Faker) -> MyProfileGet: fake_profile: dict[str, Any] = faker.simple_profile() first, last = fake_profile["name"].rsplit(maxsplit=1) - return ProfileGet( + return MyProfileGet( id=faker.pyint(), first_name=first, last_name=last, user_name=fake_profile["username"], login=fake_profile["mail"], role="USER", - privacy=ProfilePrivacyGet(hide_fullname=True, hide_email=True), + privacy=MyProfilePrivacyGet(hide_fullname=True, hide_email=True), preferences={}, ) -def test_profile_get_expiration_date(fake_profile_get: ProfileGet): +def test_profile_get_expiration_date(fake_profile_get: MyProfileGet): fake_expiration = datetime.now(UTC) profile = fake_profile_get.model_copy( @@ -80,7 +80,7 @@ def test_profile_get_expiration_date(fake_profile_get: ProfileGet): assert body["expirationDate"] == fake_expiration.date().isoformat() -def test_auto_compute_gravatar__deprecated(fake_profile_get: ProfileGet): +def test_auto_compute_gravatar__deprecated(fake_profile_get: MyProfileGet): profile = fake_profile_get.model_copy() @@ -89,7 +89,7 @@ def test_auto_compute_gravatar__deprecated(fake_profile_get: ProfileGet): assert ( "gravatar_id" not in data - ), f"{ProfileGet.model_fields['gravatar_id'].deprecated=}" + ), f"{MyProfileGet.model_fields['gravatar_id'].deprecated=}" assert data["id"] == profile.id assert data["first_name"] == profile.first_name assert data["last_name"] == profile.last_name @@ -100,13 +100,13 @@ def test_auto_compute_gravatar__deprecated(fake_profile_get: ProfileGet): @pytest.mark.parametrize("user_role", [u.name for u in UserRole]) def test_profile_get_role(user_role: str): - for example in ProfileGet.model_json_schema()["examples"]: + for example in MyProfileGet.model_json_schema()["examples"]: data = deepcopy(example) data["role"] = user_role - m1 = ProfileGet(**data) + m1 = MyProfileGet(**data) data["role"] = UserRole(user_role) - m2 = ProfileGet(**data) + m2 = MyProfileGet(**data) assert m1 == m2 @@ -155,13 +155,13 @@ def test_parsing_output_of_get_user_profile(): }, } - profile = ProfileGet.model_validate(result_from_db_query_and_composition) + profile = MyProfileGet.model_validate(result_from_db_query_and_composition) assert "password" not in profile.model_dump(exclude_unset=True) def test_mapping_update_models_from_rest_to_db(): - profile_update = ProfileUpdate.model_validate( + profile_update = MyProfilePatch.model_validate( # request payload { "first_name": "foo", diff --git a/services/web/server/tests/unit/with_dbs/01/groups/conftest.py b/services/web/server/tests/unit/with_dbs/01/groups/conftest.py new file mode 100644 index 00000000000..67e733cfd78 --- /dev/null +++ b/services/web/server/tests/unit/with_dbs/01/groups/conftest.py @@ -0,0 +1,43 @@ +# pylint: disable=redefined-outer-name +# pylint: disable=too-many-arguments +# pylint: disable=too-many-statements +# pylint: disable=unused-argument +# pylint: disable=unused-variable + + +from collections.abc import Callable + +import pytest +import sqlalchemy as sa +from aiohttp.test_utils import TestClient +from pytest_simcore.helpers.typing_env import EnvVarsDict +from servicelib.aiohttp.application import create_safe_application +from simcore_service_webserver.application_settings import setup_settings +from simcore_service_webserver.db.plugin import setup_db +from simcore_service_webserver.groups.plugin import setup_groups +from simcore_service_webserver.login.plugin import setup_login +from simcore_service_webserver.rest.plugin import setup_rest +from simcore_service_webserver.security.plugin import setup_security +from simcore_service_webserver.session.plugin import setup_session +from simcore_service_webserver.users.plugin import setup_users + + +@pytest.fixture +def client( + event_loop, + aiohttp_client: Callable, + app_environment: EnvVarsDict, + postgres_db: sa.engine.Engine, +) -> TestClient: + app = create_safe_application() + + setup_settings(app) + setup_db(app) + setup_session(app) + setup_security(app) + setup_rest(app) + setup_login(app) + setup_users(app) + setup_groups(app) + + return event_loop.run_until_complete(aiohttp_client(app)) diff --git a/services/web/server/tests/unit/with_dbs/01/groups/test_groups_handlers_crud.py b/services/web/server/tests/unit/with_dbs/01/groups/test_groups_handlers_crud.py new file mode 100644 index 00000000000..5adaf33d9af --- /dev/null +++ b/services/web/server/tests/unit/with_dbs/01/groups/test_groups_handlers_crud.py @@ -0,0 +1,210 @@ +# pylint: disable=redefined-outer-name +# pylint: disable=too-many-arguments +# pylint: disable=too-many-statements +# pylint: disable=unused-argument +# pylint: disable=unused-variable + + +import operator + +import pytest +from aiohttp.test_utils import TestClient +from models_library.api_schemas_webserver.groups import GroupGet, MyGroupsGet +from pydantic import TypeAdapter +from pytest_simcore.helpers.assert_checks import assert_status +from pytest_simcore.helpers.webserver_login import UserInfoDict +from pytest_simcore.helpers.webserver_parametrizations import ( + ExpectedResponse, + standard_role_response, +) +from servicelib.aiohttp import status +from simcore_postgres_database.models.users import UserRole +from simcore_service_webserver._meta import API_VTAG + + +@pytest.mark.parametrize(*standard_role_response(), ids=str) +async def test_groups_access_rights( + client: TestClient, + logged_user: UserInfoDict, + user_role: UserRole, + expected: ExpectedResponse, +): + assert client.app + url = client.app.router["list_groups"].url_for() + assert f"{url}" == f"/{API_VTAG}/groups" + + response = await client.get(f"{url}") + await assert_status( + response, expected.ok if user_role != UserRole.GUEST else status.HTTP_200_OK + ) + + url = client.app.router["create_group"].url_for() + assert f"{url}" == f"/{API_VTAG}/groups" + resp = await client.post( + f"{url}", + json={"label": "Black Sabbath", "description": "The founders of Rock'N'Roll"}, + ) + await assert_status(resp, expected.created) + + +@pytest.mark.parametrize("user_role", [UserRole.USER]) +async def test_list_user_groups_and_try_modify_organizations( + client: TestClient, + user_role: UserRole, + standard_groups_owner: UserInfoDict, + logged_user: UserInfoDict, + primary_group: dict[str, str], + standard_groups: list[dict[str, str]], + all_group: dict[str, str], +): + assert client.app + assert logged_user["id"] != standard_groups_owner["id"] + assert logged_user["role"] == user_role.value + + # List all groups (organizations, primary, everyone and products) I belong to + url = client.app.router["list_groups"].url_for() + assert f"{url}" == f"/{API_VTAG}/groups" + + response = await client.get(f"{url}") + data, error = await assert_status(response, status.HTTP_200_OK) + + my_groups = MyGroupsGet.model_validate(data) + assert not error + + assert my_groups.me.model_dump(by_alias=True) == primary_group + assert my_groups.all.model_dump(by_alias=True) == all_group + + assert my_groups.organizations + assert len(my_groups.organizations) == len(standard_groups) + + by_gid = operator.itemgetter("gid") + assert sorted( + TypeAdapter(list[GroupGet]).dump_python( + my_groups.organizations, mode="json", by_alias=True + ), + key=by_gid, + ) == sorted(standard_groups, key=by_gid) + + for group in standard_groups: + # try to delete a group + url = client.app.router["delete_group"].url_for(gid=f"{group['gid']}") + response = await client.delete(f"{url}") + await assert_status(response, status.HTTP_403_FORBIDDEN) + + # try to add some user in the group + url = client.app.router["add_group_user"].url_for(gid=f"{group['gid']}") + response = await client.post(f"{url}", json={"uid": logged_user["id"]}) + await assert_status(response, status.HTTP_403_FORBIDDEN) + + # try to modify the user in the group + url = client.app.router["update_group_user"].url_for( + gid=f"{group['gid']}", uid=f"{logged_user['id']}" + ) + response = await client.patch( + f"{url}", + json={"accessRights": {"read": True, "write": True, "delete": True}}, + ) + await assert_status(response, status.HTTP_403_FORBIDDEN) + + # try to remove the user from the group + url = client.app.router["delete_group_user"].url_for( + gid=f"{group['gid']}", uid=f"{logged_user['id']}" + ) + response = await client.delete(f"{url}") + await assert_status(response, status.HTTP_403_FORBIDDEN) + + +@pytest.mark.parametrize("user_role", [UserRole.USER]) +async def test_group_creation_workflow( + client: TestClient, + user_role: UserRole, + logged_user: UserInfoDict, +): + assert client.app + assert logged_user["id"] != 0 + assert logged_user["role"] == user_role.value + + url = client.app.router["create_group"].url_for() + new_group_data = { + "label": "Black Sabbath", + "description": "The founders of Rock'N'Roll", + "thumbnail": "https://www.startpage.com/av/proxy-image?piurl=https%3A%2F%2Fencrypted-tbn0.gstatic.com%2Fimages%3Fq%3Dtbn%3AANd9GcS3pAUISv_wtYDL9Ih4JtUfAWyHj9PkYMlEBGHJsJB9QlTZuuaK%26s&sp=1591105967T00f0b7ff95c7b3bca035102fa1ead205ab29eb6cd95acedcedf6320e64634f0c", + } + + resp = await client.post(f"{url}", json=new_group_data) + data, error = await assert_status(resp, status.HTTP_201_CREATED) + + assert not error + group = GroupGet.model_validate(data) + + # we get a new gid and the rest keeps the same + assert ( + group.model_dump(include={"label", "description", "thumbnail"}, mode="json") + == new_group_data + ) + + # we get full ownership (i.e all rights) on the group since we are the creator + assert group.access_rights.model_dump() == { + "read": True, + "write": True, + "delete": True, + } + + # get the groups and check we are part of this new group + url = client.app.router["list_groups"].url_for() + resp = await client.get(f"{url}") + data, _ = await assert_status(resp, status.HTTP_200_OK) + + my_groups = MyGroupsGet.model_validate(data) + assert my_groups.organizations + assert len(my_groups.organizations) == 1 + assert ( + my_groups.organizations[0].model_dump(include=set(new_group_data), mode="json") + == new_group_data + ) + + # check getting one group + url = client.app.router["get_group"].url_for(gid=f"{group.gid}") + resp = await client.get(f"{url}") + data, _ = await assert_status(resp, status.HTTP_200_OK) + + got_group = GroupGet.model_validate(data) + assert got_group == group + + # modify the group + url = client.app.router["update_group"].url_for(gid=f"{group.gid}") + resp = await client.patch(f"{url}", json={"label": "Led Zeppelin"}) + data, _ = await assert_status(resp, status.HTTP_200_OK) + + updated_group = GroupGet.model_validate(data) + assert updated_group.model_dump(exclude={"label"}) == got_group.model_dump( + exclude={"label"} + ) + assert updated_group.label == "Led Zeppelin" + + # check getting the group returns the newly modified group + url = client.app.router["get_group"].url_for(gid=f"{updated_group.gid}") + resp = await client.get(f"{url}") + data, _ = await assert_status(resp, status.HTTP_200_OK) + + got_group = GroupGet.model_validate(data) + assert got_group == updated_group + + # delete the group + url = client.app.router["delete_group"].url_for(gid=f"{updated_group.gid}") + resp = await client.delete(f"{url}") + await assert_status(resp, status.HTTP_204_NO_CONTENT) + + # check deleting the same group again fails + url = client.app.router["delete_group"].url_for(gid=f"{updated_group.gid}") + resp = await client.delete(f"{url}") + _, error = await assert_status(resp, status.HTTP_404_NOT_FOUND) + + assert f"{group.gid}" in error["message"] + + # check getting the group fails + url = client.app.router["get_group"].url_for(gid=f"{updated_group.gid}") + resp = await client.get(f"{url}") + _, error = await assert_status(resp, status.HTTP_404_NOT_FOUND) + + assert f"{group.gid}" in error["message"] diff --git a/services/web/server/tests/unit/with_dbs/01/groups/test_groups_handlers_users.py b/services/web/server/tests/unit/with_dbs/01/groups/test_groups_handlers_users.py new file mode 100644 index 00000000000..97ebd6e2b51 --- /dev/null +++ b/services/web/server/tests/unit/with_dbs/01/groups/test_groups_handlers_users.py @@ -0,0 +1,516 @@ +# pylint: disable=redefined-outer-name +# pylint: disable=too-many-arguments +# pylint: disable=too-many-statements +# pylint: disable=unused-argument +# pylint: disable=unused-variable + +from collections.abc import AsyncIterator +from contextlib import AsyncExitStack + +import pytest +from aiohttp.test_utils import TestClient +from faker import Faker +from models_library.api_schemas_webserver.groups import GroupGet, GroupUserGet +from models_library.groups import AccessRightsDict, Group, StandardGroupCreate +from pytest_simcore.helpers.assert_checks import assert_status +from pytest_simcore.helpers.webserver_login import LoggedUser, NewUser, UserInfoDict +from pytest_simcore.helpers.webserver_parametrizations import ( + ExpectedResponse, + standard_role_response, +) +from servicelib.aiohttp import status +from simcore_postgres_database.models.users import UserRole +from simcore_service_webserver._meta import API_VTAG +from simcore_service_webserver.groups._groups_api import ( + create_standard_group, + delete_standard_group, +) +from simcore_service_webserver.groups._groups_db import ( + _DEFAULT_GROUP_OWNER_ACCESS_RIGHTS, + _DEFAULT_GROUP_READ_ACCESS_RIGHTS, +) +from simcore_service_webserver.groups.api import auto_add_user_to_groups +from simcore_service_webserver.security.api import clean_auth_policy_cache + + +def _assert_group(group: dict[str, str]): + return GroupGet.model_validate(group) + + +def _assert__group_user( + expected_user: UserInfoDict, + expected_access_rights: AccessRightsDict, + actual_user: dict, + group_owner_id: int, +): + user = GroupUserGet.model_validate(actual_user) + + assert user.id + assert user.gid + + # identifiers + assert actual_user["userName"] == expected_user["name"] + assert "id" in actual_user + assert int(user.id) == expected_user["id"] + + assert "gid" in actual_user + assert int(user.gid) == expected_user.get("primary_gid") + + # private profile + is_private = int(group_owner_id) != int(actual_user["id"]) + assert "first_name" in actual_user + assert actual_user["first_name"] == ( + None if is_private else expected_user.get("first_name") + ) + assert "last_name" in actual_user + assert actual_user["last_name"] == ( + None if is_private else expected_user.get("last_name") + ) + assert "login" in actual_user + assert actual_user["login"] == (None if is_private else expected_user["email"]) + + # access-rights + assert "accessRights" in actual_user + assert actual_user["accessRights"] == expected_access_rights + + +@pytest.mark.parametrize(*standard_role_response()) +async def test_add_remove_users_from_group( + client: TestClient, + logged_user: UserInfoDict, + user_role: UserRole, + expected: ExpectedResponse, + faker: Faker, +): + assert client.app + new_group = { + "gid": "5", + "label": "team awesom", + "description": "awesomeness is just the summary", + "thumbnail": "https://www.startpage.com/av/proxy-image?piurl=https%3A%2F%2Fencrypted-tbn0.gstatic.com%2Fimages%3Fq%3Dtbn%3AANd9GcSQMopBeN0pq2gg6iIZuLGYniFxUdzi7a2LeT1Xg0Lz84bl36Nlqw%26s&sp=1591110539Tbbb022a272bc117e58cca2f2399e83e6b5d4a2d0a7c283330057d7718ae305bd", + } + + # check that our group does not exist + url = client.app.router["get_all_group_users"].url_for(gid=new_group["gid"]) + assert f"{url}" == f"/{API_VTAG}/groups/{new_group['gid']}/users" + resp = await client.get(f"{url}") + data, error = await assert_status(resp, expected.not_found) + + # Create group + url = client.app.router["create_group"].url_for() + assert f"{url}" == f"/{API_VTAG}/groups" + resp = await client.post(f"{url}", json=new_group) + data, error = await assert_status(resp, expected.created) + + assigned_group = new_group + if not error: + assert isinstance(data, dict) + assigned_group = data + + _assert_group(assigned_group) + + # we get a new gid and the rest keeps the same + assert assigned_group["gid"] != new_group["gid"] + + props = ["label", "description", "thumbnail"] + assert {assigned_group[p] for p in props} == {new_group[p] for p in props} + + # we get all rights on the group since we are the creator + assert assigned_group["accessRights"] == _DEFAULT_GROUP_OWNER_ACCESS_RIGHTS + + group_id = assigned_group["gid"] + + # check that our user is in the group of users + url = client.app.router["get_all_group_users"].url_for(gid=f"{group_id}") + assert f"{url}" == f"/{API_VTAG}/groups/{group_id}/users" + resp = await client.get(f"{url}") + data, error = await assert_status(resp, expected.ok) + + if not error: + list_of_users = data + assert len(list_of_users) == 1 + the_owner = list_of_users[0] + _assert__group_user( + logged_user, + _DEFAULT_GROUP_OWNER_ACCESS_RIGHTS, + the_owner, + group_owner_id=the_owner["id"], + ) + + # create a random number of users and put them in the group + num_new_users = faker.random_int(1, 10) + created_users_list = [] + async with AsyncExitStack() as users_stack: + for i in range(num_new_users): + + is_private = i % 2 == 0 + created_users_list.append( + await users_stack.enter_async_context( + NewUser( + app=client.app, user_data={"privacy_hide_email": is_private} + ) + ) + ) + created_users_list[i]["is_private"] = is_private + user_id = created_users_list[i]["id"] + user_email = created_users_list[i]["email"] + + # ADD + url = client.app.router["add_group_user"].url_for(gid=f"{group_id}") + assert f"{url}" == f"/{API_VTAG}/groups/{group_id}/users" + if is_private: + # only if privacy allows + resp = await client.post(f"{url}", json={"email": user_email}) + data, error = await assert_status(resp, expected.not_found) + + # always allowed + resp = await client.post(f"{url}", json={"uid": user_id}) + await assert_status(resp, expected.no_content) + else: + # both work + resp = await client.post(f"{url}", json={"email": user_email}) + await assert_status(resp, expected.no_content) + + # GET + url = client.app.router["get_group_user"].url_for( + gid=f"{group_id}", uid=f"{user_id}" + ) + assert f"{url}" == f"/{API_VTAG}/groups/{group_id}/users/{user_id}" + resp = await client.get(f"{url}") + data, error = await assert_status(resp, expected.ok) + if not error: + _assert__group_user( + created_users_list[i], + _DEFAULT_GROUP_READ_ACCESS_RIGHTS, + data, + group_owner_id=the_owner["id"] if is_private else user_id, + ) + + # LIST: check list is correct + url = client.app.router["get_all_group_users"].url_for(gid=f"{group_id}") + resp = await client.get(f"{url}") + data, error = await assert_status(resp, expected.ok) + if not error: + list_of_users = data + + # now we should have all the users in the group + the owner + all_created_users = [*created_users_list, logged_user] + + assert len(list_of_users) == len(all_created_users) + for user in list_of_users: + expected_user: UserInfoDict = next( + u for u in all_created_users if int(u["id"]) == int(user["id"]) + ) + expected_access_rigths = ( + _DEFAULT_GROUP_OWNER_ACCESS_RIGHTS + if int(user["id"]) == int(logged_user["id"]) + else _DEFAULT_GROUP_READ_ACCESS_RIGHTS + ) + + _assert__group_user( + expected_user, + expected_access_rigths, + user, + group_owner_id=the_owner["id"] + if expected_user.get("is_private", False) + else user["id"], + ) + + # PATCH the user and REMOVE them from the group + MANAGER_ACCESS_RIGHTS: AccessRightsDict = { + "read": True, + "write": True, + "delete": False, + } + for i in range(num_new_users): + group_id = assigned_group["gid"] + user_id = created_users_list[i]["id"] + is_private = created_users_list[i].get("is_private", False) + + # PATCH access-rights + url = client.app.router["update_group_user"].url_for( + gid=f"{group_id}", uid=f"{user_id}" + ) + resp = await client.patch( + f"{url}", json={"accessRights": MANAGER_ACCESS_RIGHTS} + ) + data, error = await assert_status(resp, expected.ok) + if not error: + _assert__group_user( + created_users_list[i], + MANAGER_ACCESS_RIGHTS, + data, + group_owner_id=the_owner["id"] if is_private else user_id, + ) + + # GET: check it is there + url = client.app.router["get_group_user"].url_for( + gid=f"{group_id}", uid=f"{user_id}" + ) + resp = await client.get(f"{url}") + data, error = await assert_status(resp, expected.ok) + if not error: + _assert__group_user( + created_users_list[i], + MANAGER_ACCESS_RIGHTS, + data, + group_owner_id=the_owner["id"] if is_private else user_id, + ) + + # REMOVE the user from the group + url = client.app.router["delete_group_user"].url_for( + gid=f"{group_id}", uid=f"{user_id}" + ) + resp = await client.delete(f"{url}") + data, error = await assert_status(resp, expected.no_content) + + # REMOVE: do it again to check it is not found anymore + resp = await client.delete(f"{url}") + data, error = await assert_status(resp, expected.not_found) + + # GET check it is not there anymore + url = client.app.router["get_group_user"].url_for( + gid=f"{group_id}", uid=f"{user_id}" + ) + resp = await client.get(f"{url}") + data, error = await assert_status(resp, expected.not_found) + + +@pytest.mark.parametrize(*standard_role_response()) +async def test_group_access_rights( + client: TestClient, + logged_user: UserInfoDict, + user_role: UserRole, + expected: ExpectedResponse, +): + assert client.app + # Use-case: + # 1. create a group + url = client.app.router["create_group"].url_for() + assert f"{url}" == f"/{API_VTAG}/groups" + + new_group = { + "gid": "4564", + "label": f"this is user {logged_user['id']} group", + "description": f"user {logged_user['email']} is the owner of that one", + "thumbnail": None, + } + + resp = await client.post(f"{url}", json=new_group) + data, error = await assert_status(resp, expected.created) + if not data: + # role cannot create a group so stop here + return + + assigned_group = data + group_id = assigned_group["gid"] + + async with AsyncExitStack() as users_stack: + # 1. have 2 users + users = [ + await users_stack.enter_async_context(NewUser(app=client.app)) + for _ in range(2) + ] + + # 2. ADD the users to the group + add_group_user_url = client.app.router["add_group_user"].url_for( + gid=f"{group_id}" + ) + assert f"{add_group_user_url}" == f"/{API_VTAG}/groups/{group_id}/users" + for user in users: + resp = await client.post(f"{add_group_user_url}", json={"uid": user["id"]}) + await assert_status(resp, expected.no_content) + + # 3. PATCH: user 1 shall be a manager + patch_group_user_url = client.app.router["update_group_user"].url_for( + gid=f"{group_id}", uid=f"{users[0]['id']}" + ) + assert ( + f"{patch_group_user_url}" + == f"/{API_VTAG}/groups/{group_id}/users/{users[0]['id']}" + ) + params = {"accessRights": {"read": True, "write": True, "delete": False}} + resp = await client.patch(f"{patch_group_user_url}", json=params) + await assert_status(resp, expected.ok) + + # 4. PATCH user 2 shall be a member + patch_group_user_url = client.app.router["update_group_user"].url_for( + gid=f"{group_id}", uid=f"{users[1]['id']}" + ) + assert ( + f"{patch_group_user_url}" + == f"/{API_VTAG}/groups/{group_id}/users/{users[1]['id']}" + ) + resp = await client.patch( + f"{patch_group_user_url}", + json={"accessRights": {"read": True, "write": False, "delete": False}}, + ) + await assert_status(resp, expected.ok) + + # let's LOGIN as user 1 + url = client.app.router["auth_login"].url_for() + resp = await client.post( + f"{url}", + json={ + "email": users[0]["email"], + "password": users[0]["raw_password"], + }, + ) + await assert_status(resp, expected.ok) + + # check as a manager I can REMOVE user 2 + delete_group_user_url = client.app.router["delete_group_user"].url_for( + gid=f"{group_id}", uid=f"{users[1]['id']}" + ) + assert ( + f"{delete_group_user_url}" + == f"/{API_VTAG}/groups/{group_id}/users/{users[1]['id']}" + ) + resp = await client.delete(f"{delete_group_user_url}") + await assert_status(resp, expected.no_content) + + # as a manager I can ADD user 2 again + resp = await client.post(f"{add_group_user_url}", json={"uid": users[1]["id"]}) + await assert_status(resp, expected.no_content) + + # as a manager I cannot DELETE the group + url = client.app.router["delete_group"].url_for(gid=f"{group_id}") + resp = await client.delete(f"{url}") + await assert_status(resp, status.HTTP_403_FORBIDDEN) + + # now log in as user 2 + # LOGIN + url = client.app.router["auth_login"].url_for() + resp = await client.post( + f"{url}", + json={ + "email": users[1]["email"], + "password": users[1]["raw_password"], + }, + ) + await assert_status(resp, expected.ok) + + # as a member I cannot REMOVE user 1 + delete_group_user_url = client.app.router["delete_group_user"].url_for( + gid=f"{group_id}", uid=f"{users[0]['id']}" + ) + assert ( + f"{delete_group_user_url}" + == f"/{API_VTAG}/groups/{group_id}/users/{users[0]['id']}" + ) + resp = await client.delete(f"{delete_group_user_url}") + await assert_status(resp, status.HTTP_403_FORBIDDEN) + + # as a member I cannot ADD user 1 + resp = await client.post(f"{add_group_user_url}", json={"uid": users[0]["id"]}) + await assert_status(resp, status.HTTP_403_FORBIDDEN) + + # as a member I cannot DELETE the grouop + url = client.app.router["delete_group"].url_for(gid=f"{group_id}") + resp = await client.delete(f"{url}") + await assert_status(resp, status.HTTP_403_FORBIDDEN) + + +@pytest.mark.parametrize(*standard_role_response()) +async def test_add_user_gets_added_to_group( + client: TestClient, + standard_groups: list[dict[str, str]], + user_role: UserRole, + expected: ExpectedResponse, +): + + assert client.app + async with AsyncExitStack() as users_stack: + for email in ( + # SEE StandardGroupCreate.inclusion_rules in + # packages/pytest-simcore/src/pytest_simcore/simcore_webserver_groups_fixtures.py + "good@sparc.io", + "bad@bad.com", + "bad@osparc.com", + "good@black.com", + "bad@blanco.com", + ): + user = await users_stack.enter_async_context( + LoggedUser( + client, + user_data={ + "role": user_role.name, + "email": email, + "privacy_hide_email": False, + }, + check_if_succeeds=user_role != UserRole.ANONYMOUS, + ) + ) + await auto_add_user_to_groups(client.app, user["id"]) + + url = client.app.router["list_groups"].url_for() + assert f"{url}" == f"/{API_VTAG}/groups" + + resp = await client.get(f"{url}") + data, error = await assert_status( + resp, status.HTTP_200_OK if user_role == UserRole.GUEST else expected.ok + ) + if not error: + assert len(data["organizations"]) == (0 if "bad" in email else 1) + + # NOTE: here same email are used for different users! Therefore sessions get mixed! + await clean_auth_policy_cache(client.app) + + +@pytest.fixture +async def group_where_logged_user_is_the_owner( + client: TestClient, + logged_user: UserInfoDict, +) -> AsyncIterator[Group]: + assert client.app + group, _ = await create_standard_group( + app=client.app, + user_id=logged_user["id"], + create=StandardGroupCreate.model_validate( + { + "name": f"this is user {logged_user['id']} group", + "description": f"user {logged_user['email']} is the owner of that one", + "thumbnail": None, + } + ), + ) + + yield group + + await delete_standard_group( + client.app, user_id=logged_user["id"], group_id=group.gid + ) + + +@pytest.mark.acceptance_test( + "Fixes 🐛 https://github.com/ITISFoundation/osparc-issues/issues/812" +) +@pytest.mark.parametrize("user_role", [UserRole.USER]) +async def test_adding_user_to_group_with_upper_case_email( + client: TestClient, + user_role: UserRole, + group_where_logged_user_is_the_owner: Group, + faker: Faker, +): + assert client.app + url = client.app.router["add_group_user"].url_for( + gid=f"{group_where_logged_user_is_the_owner.gid}" + ) + # adding a user to group with the email in capital letters + # Tests 🐛 https://github.com/ITISFoundation/osparc-issues/issues/812 + async with NewUser( + app=client.app, user_data={"privacy_hide_email": False} + ) as registered_user: + assert registered_user["email"] # <--- this email is lower case + + response = await client.post( + f"{url}", + json={ + # <--- email in upper case + "email": registered_user["email"].upper() + }, + ) + data, error = await assert_status(response, status.HTTP_204_NO_CONTENT) + + assert not data + assert not error diff --git a/services/web/server/tests/unit/with_dbs/01/test_groups.py b/services/web/server/tests/unit/with_dbs/01/test_groups.py deleted file mode 100644 index 51f2f746a80..00000000000 --- a/services/web/server/tests/unit/with_dbs/01/test_groups.py +++ /dev/null @@ -1,668 +0,0 @@ -# pylint: disable=redefined-outer-name -# pylint: disable=too-many-arguments -# pylint: disable=too-many-statements -# pylint: disable=unused-argument -# pylint: disable=unused-variable - -from collections.abc import AsyncIterator, Callable -from contextlib import AsyncExitStack -from copy import deepcopy -from typing import Any - -import pytest -from aiohttp.test_utils import TestClient -from faker import Faker -from pytest_simcore.helpers.assert_checks import assert_status -from pytest_simcore.helpers.webserver_login import LoggedUser, NewUser, UserInfoDict -from pytest_simcore.helpers.webserver_parametrizations import ( - ExpectedResponse, - standard_role_response, -) -from servicelib.aiohttp import status -from servicelib.aiohttp.application import create_safe_application -from simcore_postgres_database.models.users import UserRole -from simcore_service_webserver._meta import API_VTAG -from simcore_service_webserver.application_settings import setup_settings -from simcore_service_webserver.db.plugin import setup_db -from simcore_service_webserver.groups._db import ( - _DEFAULT_GROUP_OWNER_ACCESS_RIGHTS, - _DEFAULT_GROUP_READ_ACCESS_RIGHTS, -) -from simcore_service_webserver.groups._utils import AccessRightsDict -from simcore_service_webserver.groups.api import ( - auto_add_user_to_groups, - create_user_group, - delete_user_group, -) -from simcore_service_webserver.groups.plugin import setup_groups -from simcore_service_webserver.login.plugin import setup_login -from simcore_service_webserver.rest.plugin import setup_rest -from simcore_service_webserver.security.api import clean_auth_policy_cache -from simcore_service_webserver.security.plugin import setup_security -from simcore_service_webserver.session.plugin import setup_session -from simcore_service_webserver.users.plugin import setup_users -from simcore_service_webserver.utils import gravatar_hash - - -@pytest.fixture -def client( - event_loop, - aiohttp_client, - app_cfg, - postgres_db, - monkeypatch_setenv_from_app_config: Callable, -) -> TestClient: - cfg = deepcopy(app_cfg) - - port = cfg["main"]["port"] - - assert cfg["rest"]["version"] == API_VTAG - monkeypatch_setenv_from_app_config(cfg) - - # fake config - app = create_safe_application(cfg) - - settings = setup_settings(app) - print(settings.model_dump_json(indent=1)) - - setup_db(app) - setup_session(app) - setup_security(app) - setup_rest(app) - setup_login(app) - setup_users(app) - setup_groups(app) - - return event_loop.run_until_complete( - aiohttp_client(app, server_kwargs={"port": port, "host": "localhost"}) - ) - - -def _assert_group(group: dict[str, str]): - properties = ["gid", "label", "description", "thumbnail", "accessRights"] - assert all(x in group for x in properties) - access_rights = group["accessRights"] - access_rights_properties = ["read", "write", "delete"] - assert all(x in access_rights for x in access_rights_properties) - - -def _assert__group_user( - expected_user: UserInfoDict, - expected_access_rights: AccessRightsDict, - actual_user: dict, -): - assert "first_name" in actual_user - assert actual_user["first_name"] == expected_user["first_name"] - assert "last_name" in actual_user - assert actual_user["last_name"] == expected_user["last_name"] - assert "login" in actual_user - assert actual_user["login"] == expected_user["email"] - assert "gravatar_id" in actual_user - assert actual_user["gravatar_id"] == gravatar_hash(expected_user["email"]) - assert "accessRights" in actual_user - assert actual_user["accessRights"] == expected_access_rights - assert "id" in actual_user - assert actual_user["id"] == expected_user["id"] - assert "gid" in actual_user - - -@pytest.mark.parametrize(*standard_role_response(), ids=str) -async def test_list_groups( - client: TestClient, - logged_user: UserInfoDict, - user_role: UserRole, - expected: ExpectedResponse, - primary_group: dict[str, str], - standard_groups: list[dict[str, str]], - all_group: dict[str, str], -): - assert client.app - url = client.app.router["list_groups"].url_for() - assert f"{url}" == f"/{API_VTAG}/groups" - - response = await client.get(f"{url}") - data, error = await assert_status( - response, expected.ok if user_role != UserRole.GUEST else status.HTTP_200_OK - ) - - if not error: - assert isinstance(data, dict) - - assert "me" in data - _assert_group(data["me"]) - assert data["me"] == primary_group - - assert "organizations" in data - assert isinstance(data["organizations"], list) - for group in data["organizations"]: - _assert_group(group) - assert data["organizations"] == standard_groups - - assert "all" in data - _assert_group(data["all"]) - assert data["all"] == all_group - - for group in standard_groups: - # try to delete a group - url = client.app.router["delete_group"].url_for(gid=f"{group['gid']}") - response = await client.delete(f"{url}") - await assert_status(response, status.HTTP_403_FORBIDDEN) - - # try to add some user in the group - url = client.app.router["add_group_user"].url_for(gid=f"{group['gid']}") - response = await client.post(f"{url}", json={"uid": logged_user["id"]}) - await assert_status(response, status.HTTP_403_FORBIDDEN) - - # try to modify the user in the group - url = client.app.router["update_group_user"].url_for( - gid=f"{group['gid']}", uid=f"{logged_user['id']}" - ) - response = await client.patch( - f"{url}", - json={"accessRights": {"read": True, "write": True, "delete": True}}, - ) - await assert_status(response, status.HTTP_403_FORBIDDEN) - - # try to remove the user from the group - url = client.app.router["delete_group_user"].url_for( - gid=f"{group['gid']}", uid=f"{logged_user['id']}" - ) - response = await client.delete(f"{url}") - await assert_status(response, status.HTTP_403_FORBIDDEN) - - -@pytest.mark.parametrize(*standard_role_response()) -async def test_group_creation_workflow( - client: TestClient, - logged_user: UserInfoDict, - user_role: UserRole, - expected: ExpectedResponse, -): - assert client.app - url = client.app.router["create_group"].url_for() - assert f"{url}" == f"/{API_VTAG}/groups" - - new_group = { - "gid": "4564", - "label": "Black Sabbath", - "description": "The founders of Rock'N'Roll", - "thumbnail": "https://www.startpage.com/av/proxy-image?piurl=https%3A%2F%2Fencrypted-tbn0.gstatic.com%2Fimages%3Fq%3Dtbn%3AANd9GcS3pAUISv_wtYDL9Ih4JtUfAWyHj9PkYMlEBGHJsJB9QlTZuuaK%26s&sp=1591105967T00f0b7ff95c7b3bca035102fa1ead205ab29eb6cd95acedcedf6320e64634f0c", - } - - resp = await client.post(f"{url}", json=new_group) - data, error = await assert_status(resp, expected.created) - - assigned_group = new_group - if not error: - assert isinstance(data, dict) - assigned_group = data - _assert_group(assigned_group) - # we get a new gid and the rest keeps the same - assert assigned_group["gid"] != new_group["gid"] - for prop in ["label", "description", "thumbnail"]: - assert assigned_group[prop] == new_group[prop] - # we get all rights on the group since we are the creator - assert assigned_group["accessRights"] == { - "read": True, - "write": True, - "delete": True, - } - - # get the groups and check we are part of this new group - url = client.app.router["list_groups"].url_for() - assert f"{url}" == f"/{API_VTAG}/groups" - - resp = await client.get(f"{url}") - data, error = await assert_status( - resp, expected.ok if user_role != UserRole.GUEST else status.HTTP_200_OK - ) - if not error and user_role != UserRole.GUEST: - assert len(data["organizations"]) == 1 - assert data["organizations"][0] == assigned_group - - # check getting one group - url = client.app.router["get_group"].url_for(gid=f"{assigned_group['gid']}") - assert f"{url}" == f"/{API_VTAG}/groups/{assigned_group['gid']}" - resp = await client.get(f"{url}") - data, error = await assert_status( - resp, expected.ok if user_role != UserRole.GUEST else status.HTTP_404_NOT_FOUND - ) - if not error: - assert data == assigned_group - - # modify the group - modified_group = {"label": "Led Zeppelin"} - url = client.app.router["update_group"].url_for(gid=f"{assigned_group['gid']}") - assert f"{url}" == f"/{API_VTAG}/groups/{assigned_group['gid']}" - resp = await client.patch(f"{url}", json=modified_group) - data, error = await assert_status(resp, expected.ok) - if not error: - assert data != assigned_group - _assert_group(data) - assigned_group.update(**modified_group) - assert data == assigned_group - # check getting the group returns the newly modified group - url = client.app.router["get_group"].url_for(gid=f"{assigned_group['gid']}") - assert f"{url}" == f"/{API_VTAG}/groups/{assigned_group['gid']}" - resp = await client.get(f"{url}") - data, error = await assert_status( - resp, expected.ok if user_role != UserRole.GUEST else status.HTTP_404_NOT_FOUND - ) - if not error: - _assert_group(data) - assert data == assigned_group - - # delete the group - url = client.app.router["delete_group"].url_for(gid=f"{assigned_group['gid']}") - assert f"{url}" == f"/{API_VTAG}/groups/{assigned_group['gid']}" - resp = await client.delete(f"{url}") - data, error = await assert_status(resp, expected.no_content) - if not error: - assert not data - - # check deleting the same group again fails - url = client.app.router["delete_group"].url_for(gid=f"{assigned_group['gid']}") - assert f"{url}" == f"/{API_VTAG}/groups/{assigned_group['gid']}" - resp = await client.delete(f"{url}") - data, error = await assert_status(resp, expected.not_found) - - # check getting the group fails - url = client.app.router["get_group"].url_for(gid=f"{assigned_group['gid']}") - assert f"{url}" == f"/{API_VTAG}/groups/{assigned_group['gid']}" - resp = await client.get(f"{url}") - data, error = await assert_status( - resp, - expected.not_found - if user_role != UserRole.GUEST - else status.HTTP_404_NOT_FOUND, - ) - - -@pytest.mark.parametrize(*standard_role_response()) -async def test_add_remove_users_from_group( - client: TestClient, - logged_user: UserInfoDict, - user_role: UserRole, - expected: ExpectedResponse, - faker: Faker, -): - assert client.app - new_group = { - "gid": "5", - "label": "team awesom", - "description": "awesomeness is just the summary", - "thumbnail": "https://www.startpage.com/av/proxy-image?piurl=https%3A%2F%2Fencrypted-tbn0.gstatic.com%2Fimages%3Fq%3Dtbn%3AANd9GcSQMopBeN0pq2gg6iIZuLGYniFxUdzi7a2LeT1Xg0Lz84bl36Nlqw%26s&sp=1591110539Tbbb022a272bc117e58cca2f2399e83e6b5d4a2d0a7c283330057d7718ae305bd", - } - - # check that our group does not exist - url = client.app.router["get_all_group_users"].url_for(gid=new_group["gid"]) - assert f"{url}" == f"/{API_VTAG}/groups/{new_group['gid']}/users" - resp = await client.get(f"{url}") - data, error = await assert_status(resp, expected.not_found) - - # Create group - url = client.app.router["create_group"].url_for() - assert f"{url}" == f"/{API_VTAG}/groups" - resp = await client.post(f"{url}", json=new_group) - data, error = await assert_status(resp, expected.created) - - assigned_group = new_group - if not error: - assert isinstance(data, dict) - assigned_group = data - - _assert_group(assigned_group) - - # we get a new gid and the rest keeps the same - assert assigned_group["gid"] != new_group["gid"] - - props = ["label", "description", "thumbnail"] - assert {assigned_group[p] for p in props} == {new_group[p] for p in props} - - # we get all rights on the group since we are the creator - assert assigned_group["accessRights"] == _DEFAULT_GROUP_OWNER_ACCESS_RIGHTS - - # check that our user is in the group of users - get_group_users_url = client.app.router["get_all_group_users"].url_for( - gid=f"{assigned_group['gid']}" - ) - assert ( - f"{get_group_users_url}" == f"/{API_VTAG}/groups/{assigned_group['gid']}/users" - ) - resp = await client.get(f"{get_group_users_url}") - data, error = await assert_status(resp, expected.ok) - - if not error: - list_of_users = data - assert len(list_of_users) == 1 - the_owner = list_of_users[0] - _assert__group_user(logged_user, _DEFAULT_GROUP_OWNER_ACCESS_RIGHTS, the_owner) - - # create a random number of users and put them in the group - add_group_user_url = client.app.router["add_group_user"].url_for( - gid=f"{assigned_group['gid']}" - ) - assert ( - f"{add_group_user_url}" == f"/{API_VTAG}/groups/{assigned_group['gid']}/users" - ) - num_new_users = faker.random_int(1, 10) - created_users_list = [] - - async with AsyncExitStack() as users_stack: - for i in range(num_new_users): - created_users_list.append( - await users_stack.enter_async_context(NewUser(app=client.app)) - ) - - # add the user once per email once per id to test both - params = ( - {"uid": created_users_list[i]["id"]} - if i % 2 == 0 - else {"email": created_users_list[i]["email"]} - ) - resp = await client.post(f"{add_group_user_url}", json=params) - data, error = await assert_status(resp, expected.no_content) - - get_group_user_url = client.app.router["get_group_user"].url_for( - gid=f"{assigned_group['gid']}", uid=f"{created_users_list[i]['id']}" - ) - assert ( - f"{get_group_user_url}" - == f"/{API_VTAG}/groups/{assigned_group['gid']}/users/{created_users_list[i]['id']}" - ) - resp = await client.get(f"{get_group_user_url}") - data, error = await assert_status(resp, expected.ok) - if not error: - _assert__group_user( - created_users_list[i], _DEFAULT_GROUP_READ_ACCESS_RIGHTS, data - ) - # check list is correct - resp = await client.get(f"{get_group_users_url}") - data, error = await assert_status(resp, expected.ok) - if not error: - list_of_users = data - - # now we should have all the users in the group + the owner - all_created_users = [*created_users_list, logged_user] - assert len(list_of_users) == len(all_created_users) - for actual_user in list_of_users: - - expected_users_list = list( - filter( - lambda x, ac=actual_user: x["email"] == ac["login"], - all_created_users, - ) - ) - assert len(expected_users_list) == 1 - expected_user = expected_users_list[0] - - expected_access_rigths = _DEFAULT_GROUP_READ_ACCESS_RIGHTS - if actual_user["login"] == logged_user["email"]: - expected_access_rigths = _DEFAULT_GROUP_OWNER_ACCESS_RIGHTS - - _assert__group_user( - expected_user, - expected_access_rigths, - actual_user, - ) - all_created_users.remove(expected_users_list[0]) - - # modify the user and remove them from the group - MANAGER_ACCESS_RIGHTS: AccessRightsDict = { - "read": True, - "write": True, - "delete": False, - } - for i in range(num_new_users): - update_group_user_url = client.app.router["update_group_user"].url_for( - gid=f"{assigned_group['gid']}", uid=f"{created_users_list[i]['id']}" - ) - resp = await client.patch( - f"{update_group_user_url}", json={"accessRights": MANAGER_ACCESS_RIGHTS} - ) - data, error = await assert_status(resp, expected.ok) - if not error: - _assert__group_user(created_users_list[i], MANAGER_ACCESS_RIGHTS, data) - # check it is there - get_group_user_url = client.app.router["get_group_user"].url_for( - gid=f"{assigned_group['gid']}", uid=f"{created_users_list[i]['id']}" - ) - resp = await client.get(f"{get_group_user_url}") - data, error = await assert_status(resp, expected.ok) - if not error: - _assert__group_user(created_users_list[i], MANAGER_ACCESS_RIGHTS, data) - # remove the user from the group - delete_group_user_url = client.app.router["delete_group_user"].url_for( - gid=f"{assigned_group['gid']}", uid=f"{created_users_list[i]['id']}" - ) - resp = await client.delete(f"{delete_group_user_url}") - data, error = await assert_status(resp, expected.no_content) - # do it again to check it is not found anymore - resp = await client.delete(f"{delete_group_user_url}") - data, error = await assert_status(resp, expected.not_found) - - # check it is not there anymore - get_group_user_url = client.app.router["get_group_user"].url_for( - gid=f"{assigned_group['gid']}", uid=f"{created_users_list[i]['id']}" - ) - resp = await client.get(f"{get_group_user_url}") - data, error = await assert_status(resp, expected.not_found) - - -@pytest.mark.parametrize(*standard_role_response()) -async def test_group_access_rights( - client: TestClient, - logged_user: UserInfoDict, - user_role: UserRole, - expected: ExpectedResponse, -): - assert client.app - # Use-case: - # 1. create a group - url = client.app.router["create_group"].url_for() - assert f"{url}" == f"/{API_VTAG}/groups" - - new_group = { - "gid": "4564", - "label": f"this is user {logged_user['id']} group", - "description": f"user {logged_user['email']} is the owner of that one", - "thumbnail": None, - } - - resp = await client.post(f"{url}", json=new_group) - data, error = await assert_status(resp, expected.created) - if not data: - # role cannot create a group so stop here - return - assigned_group = data - - async with AsyncExitStack() as users_stack: - # 1. have 2 users - users = [ - await users_stack.enter_async_context(NewUser(app=client.app)) - for _ in range(2) - ] - - # 2. add the users to the group - add_group_user_url = client.app.router["add_group_user"].url_for( - gid=f"{assigned_group['gid']}" - ) - assert ( - f"{add_group_user_url}" - == f"/{API_VTAG}/groups/{assigned_group['gid']}/users" - ) - for i, user in enumerate(users): - params = {"uid": user["id"]} if i % 2 == 0 else {"email": user["email"]} - resp = await client.post(f"{add_group_user_url}", json=params) - data, error = await assert_status(resp, expected.no_content) - # 3. user 1 shall be a manager - patch_group_user_url = client.app.router["update_group_user"].url_for( - gid=f"{assigned_group['gid']}", uid=f"{users[0]['id']}" - ) - assert ( - f"{patch_group_user_url}" - == f"/{API_VTAG}/groups/{assigned_group['gid']}/users/{users[0]['id']}" - ) - params = {"accessRights": {"read": True, "write": True, "delete": False}} - resp = await client.patch(f"{patch_group_user_url}", json=params) - data, error = await assert_status(resp, expected.ok) - # 4. user 2 shall be a member - patch_group_user_url = client.app.router["update_group_user"].url_for( - gid=f"{assigned_group['gid']}", uid=f"{users[1]['id']}" - ) - assert ( - f"{patch_group_user_url}" - == f"/{API_VTAG}/groups/{assigned_group['gid']}/users/{users[1]['id']}" - ) - params = {"accessRights": {"read": True, "write": False, "delete": False}} - resp = await client.patch(f"{patch_group_user_url}", json=params) - data, error = await assert_status(resp, expected.ok) - - # let's login as user 1 - # login - url = client.app.router["auth_login"].url_for() - resp = await client.post( - f"{url}", - json={ - "email": users[0]["email"], - "password": users[0]["raw_password"], - }, - ) - await assert_status(resp, expected.ok) - # check as a manager I can remove user 2 - delete_group_user_url = client.app.router["delete_group_user"].url_for( - gid=f"{assigned_group['gid']}", uid=f"{users[1]['id']}" - ) - assert ( - f"{delete_group_user_url}" - == f"/{API_VTAG}/groups/{assigned_group['gid']}/users/{users[1]['id']}" - ) - resp = await client.delete(f"{delete_group_user_url}") - data, error = await assert_status(resp, expected.no_content) - # as a manager I can add user 2 again - resp = await client.post(f"{add_group_user_url}", json={"uid": users[1]["id"]}) - data, error = await assert_status(resp, expected.no_content) - # as a manager I cannot delete the group - url = client.app.router["delete_group"].url_for(gid=f"{assigned_group['gid']}") - resp = await client.delete(f"{url}") - data, error = await assert_status(resp, status.HTTP_403_FORBIDDEN) - - # now log in as user 2 - # login - url = client.app.router["auth_login"].url_for() - resp = await client.post( - f"{url}", - json={ - "email": users[1]["email"], - "password": users[1]["raw_password"], - }, - ) - await assert_status(resp, expected.ok) - # as a member I cannot remove user 1 - delete_group_user_url = client.app.router["delete_group_user"].url_for( - gid=f"{assigned_group['gid']}", uid=f"{users[0]['id']}" - ) - assert ( - f"{delete_group_user_url}" - == f"/{API_VTAG}/groups/{assigned_group['gid']}/users/{users[0]['id']}" - ) - resp = await client.delete(f"{delete_group_user_url}") - data, error = await assert_status(resp, status.HTTP_403_FORBIDDEN) - # as a member I cannot add user 1 - resp = await client.post(f"{add_group_user_url}", json={"uid": users[0]["id"]}) - data, error = await assert_status(resp, status.HTTP_403_FORBIDDEN) - # as a member I cannot delete the grouop - url = client.app.router["delete_group"].url_for(gid=f"{assigned_group['gid']}") - resp = await client.delete(f"{url}") - data, error = await assert_status(resp, status.HTTP_403_FORBIDDEN) - - -@pytest.mark.parametrize(*standard_role_response()) -async def test_add_user_gets_added_to_group( - client: TestClient, - standard_groups: list[dict[str, str]], - user_role: UserRole, - expected: ExpectedResponse, -): - assert client.app - async with AsyncExitStack() as users_stack: - for email in ( - "good@sparc.io", - "bad@bad.com", - "bad@osparc.com", - "good@black.com", - "bad@blanco.com", - ): - user = await users_stack.enter_async_context( - LoggedUser( - client, - user_data={"role": user_role.name, "email": email}, - check_if_succeeds=user_role != UserRole.ANONYMOUS, - ) - ) - await auto_add_user_to_groups(client.app, user["id"]) - - url = client.app.router["list_groups"].url_for() - assert f"{url}" == f"/{API_VTAG}/groups" - - resp = await client.get(f"{url}") - data, error = await assert_status( - resp, status.HTTP_200_OK if user_role == UserRole.GUEST else expected.ok - ) - if not error: - assert len(data["organizations"]) == (0 if "bad" in email else 1) - - # NOTE: here same email are used for different users! Therefore sessions get mixed! - await clean_auth_policy_cache(client.app) - - -@pytest.fixture -async def group_where_logged_user_is_the_owner( - client: TestClient, logged_user: UserInfoDict -) -> AsyncIterator[dict[str, Any]]: - assert client.app - group = await create_user_group( - app=client.app, - user_id=logged_user["id"], - new_group={ - "gid": "6543", - "label": f"this is user {logged_user['id']} group", - "description": f"user {logged_user['email']} is the owner of that one", - "thumbnail": None, - }, - ) - yield group - await delete_user_group(client.app, logged_user["id"], group["gid"]) - - -@pytest.mark.acceptance_test( - "Fixes 🐛 https://github.com/ITISFoundation/osparc-issues/issues/812" -) -@pytest.mark.parametrize("user_role", [UserRole.USER]) -async def test_adding_user_to_group_with_upper_case_email( - client: TestClient, - user_role: UserRole, - group_where_logged_user_is_the_owner: dict[str, str], - faker: Faker, -): - assert client.app - url = client.app.router["add_group_user"].url_for( - gid=f"{group_where_logged_user_is_the_owner['gid']}" - ) - # adding a user to group with the email in capital letters - # Tests 🐛 https://github.com/ITISFoundation/osparc-issues/issues/812 - async with NewUser( - app=client.app, - ) as registered_user: - assert registered_user["email"] # <--- this email is lower case - - response = await client.post( - f"{url}", - json={ - "email": registered_user["email"].upper() - }, # <--- email in upper case - ) - data, error = await assert_status(response, status.HTTP_204_NO_CONTENT) - - assert not data - assert not error diff --git a/services/web/server/tests/unit/with_dbs/01/test_groups_classifiers.py b/services/web/server/tests/unit/with_dbs/01/test_groups_classifiers.py index b2fc82f44e6..354a30ef1d9 100644 --- a/services/web/server/tests/unit/with_dbs/01/test_groups_classifiers.py +++ b/services/web/server/tests/unit/with_dbs/01/test_groups_classifiers.py @@ -8,7 +8,7 @@ import sqlalchemy as sa from servicelib.common_aiopg_utils import DataSourceName, create_pg_engine from simcore_service_webserver._constants import APP_AIOPG_ENGINE_KEY -from simcore_service_webserver.groups._classifiers import GroupClassifierRepository +from simcore_service_webserver.groups._classifiers_api import GroupClassifierRepository from sqlalchemy.sql import text diff --git a/services/web/server/tests/unit/with_dbs/02/test_projects_crud_handlers.py b/services/web/server/tests/unit/with_dbs/02/test_projects_crud_handlers.py index 26d6f0cfb0e..95a2671739b 100644 --- a/services/web/server/tests/unit/with_dbs/02/test_projects_crud_handlers.py +++ b/services/web/server/tests/unit/with_dbs/02/test_projects_crud_handlers.py @@ -33,10 +33,8 @@ from simcore_postgres_database.models.projects_to_products import projects_to_products from simcore_service_webserver._meta import api_version_prefix from simcore_service_webserver.db.models import UserRole -from simcore_service_webserver.groups.api import ( - auto_add_user_to_product_group, - get_product_group_for_user, -) +from simcore_service_webserver.groups._groups_api import get_product_group_for_user +from simcore_service_webserver.groups.api import auto_add_user_to_product_group from simcore_service_webserver.groups.exceptions import GroupNotFoundError from simcore_service_webserver.products.api import get_product from simcore_service_webserver.projects._permalink_api import ProjectPermalink @@ -294,9 +292,14 @@ async def logged_user_registed_in_two_products( # registered to osparc osparc_product = get_product(client.app, "osparc") assert osparc_product.group_id - assert await get_product_group_for_user( - client.app, user_id=logged_user["id"], product_gid=osparc_product.group_id + + group, _ = await get_product_group_for_user( + # should not raise + client.app, + user_id=logged_user["id"], + product_gid=osparc_product.group_id, ) + assert group.gid == osparc_product.group_id # not registered to s4l s4l_product = get_product(client.app, s4l_products_db_name) @@ -312,9 +315,13 @@ async def logged_user_registed_in_two_products( client.app, user_id=logged_user["id"], product_name=s4l_products_db_name ) - assert await get_product_group_for_user( - client.app, user_id=logged_user["id"], product_gid=s4l_product.group_id + group, _ = await get_product_group_for_user( + # should not raise + client.app, + user_id=logged_user["id"], + product_gid=s4l_product.group_id, ) + assert group.gid == s4l_product.group_id @pytest.mark.parametrize( diff --git a/services/web/server/tests/unit/with_dbs/03/login/test_login_registration.py b/services/web/server/tests/unit/with_dbs/03/login/test_login_registration.py index 762642dfb5c..0ece8630d0f 100644 --- a/services/web/server/tests/unit/with_dbs/03/login/test_login_registration.py +++ b/services/web/server/tests/unit/with_dbs/03/login/test_login_registration.py @@ -9,7 +9,7 @@ import pytest from aiohttp.test_utils import TestClient from faker import Faker -from models_library.api_schemas_webserver.users import ProfileGet +from models_library.api_schemas_webserver.users import MyProfileGet from models_library.products import ProductName from pytest_mock import MockerFixture from pytest_simcore.helpers.assert_checks import assert_error, assert_status @@ -494,7 +494,7 @@ async def test_registraton_with_invitation_for_trial_account( url = client.app.router["get_my_profile"].url_for() response = await client.get(url.path) data, _ = await assert_status(response, status.HTTP_200_OK) - profile = ProfileGet.model_validate(data) + profile = MyProfileGet.model_validate(data) expected = invitation.user["created_at"] + timedelta(days=TRIAL_DAYS) assert profile.expiration_date diff --git a/services/web/server/tests/unit/with_dbs/03/test_users.py b/services/web/server/tests/unit/with_dbs/03/test_users.py index 4e2829c6fce..a872b98858c 100644 --- a/services/web/server/tests/unit/with_dbs/03/test_users.py +++ b/services/web/server/tests/unit/with_dbs/03/test_users.py @@ -18,7 +18,7 @@ from aiopg.sa.connection import SAConnection from faker import Faker from models_library.api_schemas_webserver.auth import AccountRequestInfo -from models_library.api_schemas_webserver.users import ProfileGet +from models_library.api_schemas_webserver.users import MyProfileGet from models_library.generics import Envelope from psycopg2 import OperationalError from pytest_simcore.helpers.assert_checks import assert_status @@ -117,7 +117,7 @@ async def test_get_profile( resp = await client.get(f"{url}") data, error = await assert_status(resp, status.HTTP_200_OK) - resp_model = Envelope[ProfileGet].model_validate(await resp.json()) + resp_model = Envelope[MyProfileGet].model_validate(await resp.json()) assert resp_model.data.model_dump(**RESPONSE_MODEL_POLICY, mode="json") == data assert resp_model.error is None @@ -202,7 +202,7 @@ async def test_profile_workflow( url = client.app.router["get_my_profile"].url_for() resp = await client.get(f"{url}") data, _ = await assert_status(resp, status.HTTP_200_OK) - my_profile = ProfileGet.model_validate(data) + my_profile = MyProfileGet.model_validate(data) url = client.app.router["update_my_profile"].url_for() resp = await client.patch( @@ -218,7 +218,7 @@ async def test_profile_workflow( url = client.app.router["get_my_profile"].url_for() resp = await client.get(f"{url}") data, _ = await assert_status(resp, status.HTTP_200_OK) - updated_profile = ProfileGet.model_validate(data) + updated_profile = MyProfileGet.model_validate(data) assert updated_profile.first_name != my_profile.first_name assert updated_profile.last_name == my_profile.last_name diff --git a/services/web/server/tests/unit/with_dbs/conftest.py b/services/web/server/tests/unit/with_dbs/conftest.py index 37217d58519..991d7fd8d56 100644 --- a/services/web/server/tests/unit/with_dbs/conftest.py +++ b/services/web/server/tests/unit/with_dbs/conftest.py @@ -19,7 +19,6 @@ from copy import deepcopy from decimal import Decimal from pathlib import Path -from typing import Any from unittest import mock from unittest.mock import AsyncMock, MagicMock @@ -47,7 +46,7 @@ from pytest_simcore.helpers.faker_factories import random_product from pytest_simcore.helpers.monkeypatch_envs import setenvs_from_dict from pytest_simcore.helpers.typing_env import EnvVarsDict -from pytest_simcore.helpers.webserver_login import NewUser, UserInfoDict +from pytest_simcore.helpers.webserver_login import UserInfoDict from pytest_simcore.helpers.webserver_parametrizations import MockedStorageSubsystem from pytest_simcore.helpers.webserver_projects import NewProject from redis import Redis @@ -69,12 +68,6 @@ from simcore_service_webserver._constants import INDEX_RESOURCE_NAME from simcore_service_webserver.application import create_application from simcore_service_webserver.db.plugin import get_database_engine -from simcore_service_webserver.groups.api import ( - add_user_in_group, - create_user_group, - delete_user_group, - list_user_groups_with_read_access, -) from simcore_service_webserver.projects.models import ProjectDict from simcore_service_webserver.statics._constants import ( FRONTEND_APP_DEFAULT, @@ -592,97 +585,6 @@ async def redis_locks_client( # Moved to packages/pytest-simcore/src/pytest_simcore/websocket_client.py -# USER GROUP FIXTURES ------------------------------------------------------- - - -@pytest.fixture -async def primary_group( - client: TestClient, - logged_user: UserInfoDict, -) -> dict[str, Any]: - assert client.app - primary_group, _, _ = await list_user_groups_with_read_access( - client.app, logged_user["id"] - ) - return primary_group - - -@pytest.fixture -async def standard_groups( - client: TestClient, - logged_user: UserInfoDict, -) -> AsyncIterator[list[dict[str, Any]]]: - assert client.app - sparc_group = { - "gid": "5", # this will be replaced - "label": "SPARC", - "description": "Stimulating Peripheral Activity to Relieve Conditions", - "thumbnail": "https://commonfund.nih.gov/sites/default/files/sparc-image-homepage500px.png", - "inclusionRules": {"email": r"@(sparc)+\.(io|com)$"}, - } - team_black_group = { - "gid": "5", # this will be replaced - "label": "team Black", - "description": "THE incredible black team", - "thumbnail": None, - "inclusionRules": {"email": r"@(black)+\.(io|com)$"}, - } - - # create a separate account to own standard groups - async with NewUser( - {"name": f"{logged_user['name']}_groups_owner", "role": "USER"}, client.app - ) as owner_user: - # creates two groups - sparc_group = await create_user_group( - app=client.app, - user_id=owner_user["id"], - new_group=sparc_group, - ) - team_black_group = await create_user_group( - app=client.app, - user_id=owner_user["id"], - new_group=team_black_group, - ) - - # adds logged_user to sparc group - await add_user_in_group( - app=client.app, - user_id=owner_user["id"], - gid=sparc_group["gid"], - new_user_id=logged_user["id"], - ) - - # adds logged_user to team-black group - await add_user_in_group( - app=client.app, - user_id=owner_user["id"], - gid=team_black_group["gid"], - new_user_email=logged_user["email"], - ) - - _, std_groups, _ = await list_user_groups_with_read_access( - client.app, logged_user["id"] - ) - - yield std_groups - - # clean groups - await delete_user_group(client.app, owner_user["id"], sparc_group["gid"]) - await delete_user_group(client.app, owner_user["id"], team_black_group["gid"]) - - -@pytest.fixture -async def all_group( - client: TestClient, - logged_user: UserInfoDict, -) -> dict[str, str]: - assert client.app - _, _, all_group = await list_user_groups_with_read_access( - client.app, logged_user["id"] - ) - return all_group - - @pytest.fixture def mock_dynamic_scheduler_rabbitmq(mocker: MockerFixture) -> None: mocker.patch(