diff --git a/api/specs/web-server/_admin.py b/api/specs/web-server/_admin.py index 767661a0dfc..87c72ce371f 100644 --- a/api/specs/web-server/_admin.py +++ b/api/specs/web-server/_admin.py @@ -28,7 +28,7 @@ response_model=Envelope[Union[EmailTestFailed, EmailTestPassed]], ) async def test_email( - _test: TestEmail, x_simcore_products_name: str | None = Header(default=None) + _body: TestEmail, x_simcore_products_name: str | None = Header(default=None) ): # X-Simcore-Products-Name ... diff --git a/api/specs/web-server/_groups.py b/api/specs/web-server/_groups.py index 530460c6d8c..6f0f1f1e616 100644 --- a/api/specs/web-server/_groups.py +++ b/api/specs/web-server/_groups.py @@ -4,6 +4,7 @@ # pylint: disable=too-many-arguments +from enum import Enum from typing import Annotated, Any from fastapi import APIRouter, Depends, status @@ -87,19 +88,24 @@ async def delete_group(_path: Annotated[GroupsPathParams, Depends()]): """ +_extra_tags: list[str | Enum] = ["users"] + + @router.get( "/groups/{gid}/users", response_model=Envelope[list[GroupUserGet]], + tags=_extra_tags, ) async def get_all_group_users(_path: Annotated[GroupsPathParams, Depends()]): """ - Gets users in organization groups + Gets users in organization or primary groups """ @router.post( "/groups/{gid}/users", status_code=status.HTTP_204_NO_CONTENT, + tags=_extra_tags, ) async def add_group_user( _path: Annotated[GroupsPathParams, Depends()], @@ -113,6 +119,7 @@ async def add_group_user( @router.get( "/groups/{gid}/users/{uid}", response_model=Envelope[GroupUserGet], + tags=_extra_tags, ) async def get_group_user( _path: Annotated[GroupsUsersPathParams, Depends()], @@ -125,6 +132,7 @@ async def get_group_user( @router.patch( "/groups/{gid}/users/{uid}", response_model=Envelope[GroupUserGet], + tags=_extra_tags, ) async def update_group_user( _path: Annotated[GroupsUsersPathParams, Depends()], @@ -138,6 +146,7 @@ async def update_group_user( @router.delete( "/groups/{gid}/users/{uid}", status_code=status.HTTP_204_NO_CONTENT, + tags=_extra_tags, ) async def delete_group_user( _path: Annotated[GroupsUsersPathParams, Depends()], diff --git a/api/specs/web-server/_users.py b/api/specs/web-server/_users.py index 95915497c52..89d5eaaba2f 100644 --- a/api/specs/web-server/_users.py +++ b/api/specs/web-server/_users.py @@ -4,6 +4,7 @@ # pylint: disable=too-many-arguments +from enum import Enum from typing import Annotated from fastapi import APIRouter, Depends, status @@ -13,8 +14,10 @@ MyProfilePatch, MyTokenCreate, MyTokenGet, + UserForAdminGet, UserGet, - UsersSearchQueryParams, + UsersForAdminSearchQueryParams, + UsersSearch, ) from models_library.api_schemas_webserver.users_preferences import PatchRequestBody from models_library.generics import Envelope @@ -29,7 +32,7 @@ from simcore_service_webserver.users._notifications_rest import _NotificationPathParams from simcore_service_webserver.users._tokens_rest import _TokenPathParams -router = APIRouter(prefix=f"/{API_VTAG}", tags=["user"]) +router = APIRouter(prefix=f"/{API_VTAG}", tags=["users"]) @router.get( @@ -44,7 +47,7 @@ async def get_my_profile(): "/me", status_code=status.HTTP_204_NO_CONTENT, ) -async def update_my_profile(_profile: MyProfilePatch): +async def update_my_profile(_body: MyProfilePatch): ... @@ -54,7 +57,7 @@ async def update_my_profile(_profile: MyProfilePatch): deprecated=True, description="Use PATCH instead", ) -async def replace_my_profile(_profile: MyProfilePatch): +async def replace_my_profile(_body: MyProfilePatch): ... @@ -64,7 +67,7 @@ async def replace_my_profile(_profile: MyProfilePatch): ) async def set_frontend_preference( preference_id: PreferenceIdentifier, - body_item: PatchRequestBody, + _body: PatchRequestBody, ): ... @@ -82,7 +85,7 @@ async def list_tokens(): response_model=Envelope[MyTokenGet], status_code=status.HTTP_201_CREATED, ) -async def create_token(_token: MyTokenCreate): +async def create_token(_body: MyTokenCreate): ... @@ -90,7 +93,9 @@ async def create_token(_token: MyTokenCreate): "/me/tokens/{service}", response_model=Envelope[MyTokenGet], ) -async def get_token(_params: Annotated[_TokenPathParams, Depends()]): +async def get_token( + _path: Annotated[_TokenPathParams, Depends()], +): ... @@ -98,7 +103,7 @@ async def get_token(_params: Annotated[_TokenPathParams, Depends()]): "/me/tokens/{service}", status_code=status.HTTP_204_NO_CONTENT, ) -async def delete_token(_params: Annotated[_TokenPathParams, Depends()]): +async def delete_token(_path: Annotated[_TokenPathParams, Depends()]): ... @@ -114,7 +119,9 @@ async def list_user_notifications(): "/me/notifications", status_code=status.HTTP_204_NO_CONTENT, ) -async def create_user_notification(_notification: UserNotificationCreate): +async def create_user_notification( + _body: UserNotificationCreate, +): ... @@ -123,8 +130,8 @@ async def create_user_notification(_notification: UserNotificationCreate): status_code=status.HTTP_204_NO_CONTENT, ) async def mark_notification_as_read( - _params: Annotated[_NotificationPathParams, Depends()], - _notification: UserNotificationPatch, + _path: Annotated[_NotificationPathParams, Depends()], + _body: UserNotificationPatch, ): ... @@ -137,24 +144,43 @@ async def list_user_permissions(): ... -@router.get( +# +# USERS public +# + + +@router.post( "/users:search", response_model=Envelope[list[UserGet]], - tags=[ - "po", - ], + description="Search among users who are publicly visible to the caller (i.e., me) based on their privacy settings.", ) -async def search_users(_params: Annotated[UsersSearchQueryParams, Depends()]): +async def search_users(_body: UsersSearch): + ... + + +# +# USERS admin +# + +_extra_tags: list[str | Enum] = ["admin"] + + +@router.get( + "/admin/users:search", + response_model=Envelope[list[UserForAdminGet]], + tags=_extra_tags, +) +async def search_users_for_admin( + _query: Annotated[UsersForAdminSearchQueryParams, Depends()] +): # NOTE: see `Search` in `Common Custom Methods` in https://cloud.google.com/apis/design/custom_methods ... @router.post( - "/users:pre-register", - response_model=Envelope[UserGet], - tags=[ - "po", - ], + "/admin/users:pre-register", + response_model=Envelope[UserForAdminGet], + tags=_extra_tags, ) -async def pre_register_user(_body: PreRegisteredUserGet): +async def pre_register_user_for_admin(_body: PreRegisteredUserGet): ... 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 7af1eeb2f96..ec9738044b4 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 @@ -29,7 +29,7 @@ ) from ..users import UserID, UserNameID from ..utils.common_validators import create__check_only_one_is_set__root_validator -from ._base import InputSchema, OutputSchema +from ._base import InputSchema, OutputSchema, OutputSchemaWithoutCamelCase S = TypeVar("S", bound=BaseModel) @@ -248,8 +248,7 @@ def from_model( ) -class GroupUserGet(BaseModel): - # OutputSchema +class GroupUserGet(OutputSchemaWithoutCamelCase): # Identifiers id: Annotated[UserID | None, Field(description="the user's id")] = None @@ -275,7 +274,14 @@ class GroupUserGet(BaseModel): ] = None # Access Rights - access_rights: GroupAccessRights = Field(..., alias="accessRights") + access_rights: Annotated[ + GroupAccessRights | None, + Field( + alias="accessRights", + description="If group is standard, these are these are the access rights of the user to it." + "None if primary group.", + ), + ] = None model_config = ConfigDict( populate_by_name=True, @@ -293,7 +299,23 @@ class GroupUserGet(BaseModel): "write": False, "delete": False, }, - } + }, + "examples": [ + # unique member on a primary group with two different primacy settings + { + "id": "16", + "userName": "mrprivate", + "gid": "55", + }, + { + "id": "56", + "userName": "mrpublic", + "login": "mrpublic@email.me", + "first_name": "Mr", + "last_name": "Public", + "gid": "42", + }, + ], }, ) 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 6fcccddaa3a..f5f49bf726c 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 @@ -3,21 +3,31 @@ from enum import Enum from typing import Annotated, Any, Literal, Self +import annotated_types from common_library.basic_types import DEFAULT_FACTORY from common_library.dict_tools import remap_keys from common_library.users_enums import UserStatus from models_library.groups import AccessRightsDict -from pydantic import BaseModel, ConfigDict, Field, ValidationInfo, field_validator +from pydantic import ( + ConfigDict, + EmailStr, + Field, + StringConstraints, + ValidationInfo, + field_validator, +) from ..basic_types import IDStr from ..emails import LowerCaseEmailStr -from ..groups import AccessRightsDict, Group, GroupsByTypeTuple +from ..groups import AccessRightsDict, Group, GroupID, GroupsByTypeTuple from ..products import ProductName +from ..rest_base import RequestParameters from ..users import ( FirstNameStr, LastNameStr, MyProfile, UserID, + UserNameID, UserPermission, UserThirdPartyToken, ) @@ -185,7 +195,37 @@ def _validate_user_name(cls, value: str): # -class UsersSearchQueryParams(BaseModel): +class UsersGetParams(RequestParameters): + user_id: UserID + + +class UsersSearch(InputSchema): + match_: Annotated[ + str, + StringConstraints(strip_whitespace=True, min_length=1, max_length=80), + Field( + description="Search string to match with usernames and public profiles (e.g. emails, first/last name)", + alias="match", + ), + ] + limit: Annotated[int, annotated_types.Interval(ge=1, le=50)] = 10 + + +class UserGet(OutputSchema): + # Public profile of a user subject to its privacy settings + user_id: UserID + group_id: GroupID + user_name: UserNameID + first_name: str | None = None + last_name: str | None = None + email: EmailStr | None = None + + @classmethod + def from_model(cls, data): + return cls.model_validate(data, from_attributes=True) + + +class UsersForAdminSearchQueryParams(RequestParameters): email: Annotated[ str, Field( @@ -196,7 +236,8 @@ class UsersSearchQueryParams(BaseModel): ] -class UserGet(OutputSchema): +class UserForAdminGet(OutputSchema): + # ONLY for admins first_name: str | None last_name: str | None email: LowerCaseEmailStr diff --git a/packages/models-library/src/models_library/groups.py b/packages/models-library/src/models_library/groups.py index a7d4810d534..c0d8692b2e7 100644 --- a/packages/models-library/src/models_library/groups.py +++ b/packages/models-library/src/models_library/groups.py @@ -108,7 +108,7 @@ class GroupMember(BaseModel): last_name: str | None # group access - access_rights: AccessRightsDict + access_rights: AccessRightsDict | None = None model_config = ConfigDict(from_attributes=True) diff --git a/packages/postgres-database/src/simcore_postgres_database/migration/versions/58012c1e1b1b_set_privacy_hide_email_to_true.py b/packages/postgres-database/src/simcore_postgres_database/migration/versions/58012c1e1b1b_set_privacy_hide_email_to_true.py new file mode 100644 index 00000000000..5c00570068a --- /dev/null +++ b/packages/postgres-database/src/simcore_postgres_database/migration/versions/58012c1e1b1b_set_privacy_hide_email_to_true.py @@ -0,0 +1,33 @@ +"""set privacy_hide_email to true. Reverts "set privacy_hide_email to false temporarily" (5e27063c3ac9) + +Revision ID: 58012c1e1b1b +Revises: 52a0e8148dd5 +Create Date: 2024-12-17 10:13:24.800681+00:00 + +""" +from alembic import op +from sqlalchemy.sql import expression + +# revision identifiers, used by Alembic. +revision = "58012c1e1b1b" +down_revision = "52a0e8148dd5" +branch_labels = None +depends_on = None + + +def upgrade(): + # 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") + + +def downgrade(): + # 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") 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 082cb7c2952..639fd05c1f7 100644 --- a/packages/postgres-database/src/simcore_postgres_database/utils_users.py +++ b/packages/postgres-database/src/simcore_postgres_database/utils_users.py @@ -10,6 +10,7 @@ import sqlalchemy as sa from aiopg.sa.connection import SAConnection from aiopg.sa.result import RowProxy +from sqlalchemy import Column from .errors import UniqueViolation from .models.users import UserRole, UserStatus, users @@ -214,7 +215,17 @@ async def is_email_used(conn: SAConnection, email: str) -> bool: users_pre_registration_details.c.pre_email == email ) ) - if pre_registered: - return True + return bool(pre_registered) + + +# +# Privacy settings +# + + +def is_private(hide_attribute: Column, caller_id: int): + return hide_attribute.is_(True) & (users.c.id != caller_id) + - return False +def is_public(hide_attribute: Column, caller_id: int): + return hide_attribute.is_(False) | (users.c.id == caller_id) diff --git a/services/static-webserver/client/source/class/osparc/data/Resources.js b/services/static-webserver/client/source/class/osparc/data/Resources.js index 16dbadc0b37..8d1ab230ee4 100644 --- a/services/static-webserver/client/source/class/osparc/data/Resources.js +++ b/services/static-webserver/client/source/class/osparc/data/Resources.js @@ -962,11 +962,11 @@ qx.Class.define("osparc.data.Resources", { endpoints: { search: { method: "GET", - url: statics.API + "/users:search?email={email}" + url: statics.API + "/admin/users:search?email={email}" }, preRegister: { method: "POST", - url: statics.API + "/users:pre-register" + url: statics.API + "/admin/users:pre-register" } } }, 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 93cf60aa82c..b04cd7e2371 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 @@ -611,8 +611,9 @@ paths: get: tags: - groups + - users summary: Get All Group Users - description: Gets users in organization groups + description: Gets users in organization or primary groups operationId: get_all_group_users parameters: - name: gid @@ -633,6 +634,7 @@ paths: post: tags: - groups + - users summary: Add Group User description: Adds a user to an organization group using their username, user ID, or email (subject to privacy settings) @@ -659,6 +661,7 @@ paths: get: tags: - groups + - users summary: Get Group User description: Gets specific user in an organization group operationId: get_group_user @@ -689,6 +692,7 @@ paths: patch: tags: - groups + - users summary: Update Group User description: Updates user (access-rights) to an organization group operationId: update_group_user @@ -725,6 +729,7 @@ paths: delete: tags: - groups + - users summary: Delete Group User description: Removes a user from an organization group operationId: delete_group_user @@ -1133,7 +1138,7 @@ paths: /v0/me: get: tags: - - user + - users summary: Get My Profile operationId: get_my_profile responses: @@ -1145,7 +1150,7 @@ paths: $ref: '#/components/schemas/Envelope_MyProfileGet_' put: tags: - - user + - users summary: Replace My Profile description: Use PATCH instead operationId: replace_my_profile @@ -1161,7 +1166,7 @@ paths: deprecated: true patch: tags: - - user + - users summary: Update My Profile operationId: update_my_profile requestBody: @@ -1176,7 +1181,7 @@ paths: /v0/me/preferences/{preference_id}: patch: tags: - - user + - users summary: Set Frontend Preference operationId: set_frontend_preference parameters: @@ -1198,7 +1203,7 @@ paths: /v0/me/tokens: get: tags: - - user + - users summary: List Tokens operationId: list_tokens responses: @@ -1210,7 +1215,7 @@ paths: $ref: '#/components/schemas/Envelope_list_MyTokenGet__' post: tags: - - user + - users summary: Create Token operationId: create_token requestBody: @@ -1229,7 +1234,7 @@ paths: /v0/me/tokens/{service}: get: tags: - - user + - users summary: Get Token operationId: get_token parameters: @@ -1248,7 +1253,7 @@ paths: $ref: '#/components/schemas/Envelope_MyTokenGet_' delete: tags: - - user + - users summary: Delete Token operationId: delete_token parameters: @@ -1264,7 +1269,7 @@ paths: /v0/me/notifications: get: tags: - - user + - users summary: List User Notifications operationId: list_user_notifications responses: @@ -1276,7 +1281,7 @@ paths: $ref: '#/components/schemas/Envelope_list_UserNotification__' post: tags: - - user + - users summary: Create User Notification operationId: create_user_notification requestBody: @@ -1291,7 +1296,7 @@ paths: /v0/me/notifications/{notification_id}: patch: tags: - - user + - users summary: Mark Notification As Read operationId: mark_notification_as_read parameters: @@ -1313,7 +1318,7 @@ paths: /v0/me/permissions: get: tags: - - user + - users summary: List User Permissions operationId: list_user_permissions responses: @@ -1324,12 +1329,33 @@ paths: schema: $ref: '#/components/schemas/Envelope_list_MyPermissionGet__' /v0/users:search: - get: + post: tags: - - user - - po + - users summary: Search Users + description: Search among users who are publicly visible to the caller (i.e., + me) based on their privacy settings. operationId: search_users + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/UsersSearch' + required: true + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/Envelope_list_UserGet__' + /v0/admin/users:search: + get: + tags: + - users + - admin + summary: Search Users For Admin + operationId: search_users_for_admin parameters: - name: email in: query @@ -1345,14 +1371,14 @@ paths: content: application/json: schema: - $ref: '#/components/schemas/Envelope_list_UserGet__' - /v0/users:pre-register: + $ref: '#/components/schemas/Envelope_list_UserForAdminGet__' + /v0/admin/users:pre-register: post: tags: - - user - - po - summary: Pre Register User - operationId: pre_register_user + - users + - admin + summary: Pre Register User For Admin + operationId: pre_register_user_for_admin requestBody: content: application/json: @@ -1365,7 +1391,7 @@ paths: content: application/json: schema: - $ref: '#/components/schemas/Envelope_UserGet_' + $ref: '#/components/schemas/Envelope_UserForAdminGet_' /v0/wallets: get: tags: @@ -8556,11 +8582,11 @@ components: title: Error type: object title: Envelope[Union[WalletGet, NoneType]] - Envelope_UserGet_: + Envelope_UserForAdminGet_: properties: data: anyOf: - - $ref: '#/components/schemas/UserGet' + - $ref: '#/components/schemas/UserForAdminGet' - type: 'null' error: anyOf: @@ -8568,7 +8594,7 @@ components: - type: 'null' title: Error type: object - title: Envelope[UserGet] + title: Envelope[UserForAdminGet] Envelope_WalletGetWithAvailableCredits_: properties: data: @@ -9202,6 +9228,22 @@ components: title: Error type: object title: Envelope[list[TaskGet]] + Envelope_list_UserForAdminGet__: + properties: + data: + anyOf: + - items: + $ref: '#/components/schemas/UserForAdminGet' + type: array + - type: 'null' + title: Data + error: + anyOf: + - {} + - type: 'null' + title: Error + type: object + title: Envelope[list[UserForAdminGet]] Envelope_list_UserGet__: properties: data: @@ -10164,11 +10206,14 @@ components: description: the user gravatar id hash deprecated: true accessRights: - $ref: '#/components/schemas/GroupAccessRights' + anyOf: + - $ref: '#/components/schemas/GroupAccessRights' + - type: 'null' + description: If group is standard, these are these are the access rights + of the user to it.None if primary group. type: object required: - userName - - accessRights title: GroupUserGet example: accessRights: @@ -14348,7 +14393,7 @@ components: - number - e_tag title: UploadedPart - UserGet: + UserForAdminGet: properties: firstName: anyOf: @@ -14439,6 +14484,45 @@ components: - country - registered - status + title: UserForAdminGet + UserGet: + properties: + userId: + type: integer + exclusiveMinimum: true + title: Userid + minimum: 0 + groupId: + type: integer + exclusiveMinimum: true + title: Groupid + minimum: 0 + userName: + type: string + maxLength: 100 + minLength: 1 + title: Username + firstName: + anyOf: + - type: string + - type: 'null' + title: Firstname + lastName: + anyOf: + - type: string + - type: 'null' + title: Lastname + email: + anyOf: + - type: string + format: email + - type: 'null' + title: Email + type: object + required: + - userId + - groupId + - userName title: UserGet UserNotification: properties: @@ -14570,6 +14654,25 @@ components: - BANNED - DELETED title: UserStatus + UsersSearch: + properties: + match: + type: string + maxLength: 80 + minLength: 1 + title: Match + description: Search string to match with usernames and public profiles (e.g. + emails, first/last name) + limit: + type: integer + maximum: 50 + minimum: 1 + title: Limit + default: 10 + type: object + required: + - match + title: UsersSearch Viewer: properties: title: diff --git a/services/web/server/src/simcore_service_webserver/groups/_groups_repository.py b/services/web/server/src/simcore_service_webserver/groups/_groups_repository.py index 7ba1b3fd25a..e4c66e489bd 100644 --- a/services/web/server/src/simcore_service_webserver/groups/_groups_repository.py +++ b/services/web/server/src/simcore_service_webserver/groups/_groups_repository.py @@ -1,9 +1,11 @@ import re from copy import deepcopy +from typing import Literal import sqlalchemy as sa from aiohttp import web from common_library.groups_enums import GroupType +from common_library.users_enums import UserRole from models_library.basic_types import IDStr from models_library.groups import ( AccessRightsDict, @@ -23,6 +25,7 @@ pass_or_acquire_connection, transaction_context, ) +from simcore_postgres_database.utils_users import is_private, is_public from sqlalchemy import and_ from sqlalchemy.dialects.postgresql import insert from sqlalchemy.engine.row import Row @@ -89,31 +92,39 @@ def _to_group_info_tuple(group: Row) -> GroupInfoTuple: def _check_group_permissions( - group: Row, user_id: int, gid: int, permission: str + group: Row, + caller_id: UserID, + group_id: GroupID, + permission: Literal["read", "write", "delete"], ) -> None: if not group.access_rights[permission]: raise UserInsufficientRightsError( - user_id=user_id, gid=gid, permission=permission + user_id=caller_id, gid=group_id, permission=permission ) async def _get_group_and_access_rights_or_raise( conn: AsyncConnection, *, - user_id: UserID, - gid: GroupID, + caller_id: UserID, + group_id: GroupID, + permission: Literal["read", "write", "delete"] | None, ) -> Row: - result = await conn.stream( + result = await conn.execute( 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)) + .select_from(groups.join(user_to_groups, user_to_groups.c.gid == groups.c.gid)) + .where((user_to_groups.c.uid == caller_id) & (user_to_groups.c.gid == group_id)) ) - row = await result.fetchone() + row = result.first() if not row: - raise GroupNotFoundError(gid=gid) + raise GroupNotFoundError(gid=group_id) + + if permission: + _check_group_permissions(row, caller_id, group_id, permission) + return row @@ -129,8 +140,10 @@ async def get_group_from_gid( 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() + row = await conn.execute( + sa.select(*_GROUP_COLUMNS).where(groups.c.gid == group_id) + ) + result = row.first() if result: return Group.model_validate(result, from_attributes=True) return None @@ -262,10 +275,8 @@ async def get_user_group( """ 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 + conn, caller_id=user_id, group_id=group_id, permission="read" ) - _check_group_permissions(row, user_id, group_id, "read") - group, access_rights = _to_group_info_tuple(row) return group, access_rights @@ -283,7 +294,10 @@ async def get_product_group_for_user( """ 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 + conn, + caller_id=user_id, + group_id=product_gid, + permission=None, ) group, access_rights = _to_group_info_tuple(row) return group, access_rights @@ -302,10 +316,12 @@ async def create_standard_group( 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) + sa.select( + users.c.primary_gid, + ).where(users.c.id == user_id) ) if not user: - raise UserNotFoundError(uid=user_id) + raise UserNotFoundError(user_id=user_id) result = await conn.stream( # pylint: disable=no-value-for-parameter @@ -348,17 +364,17 @@ async def update_standard_group( 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 + conn, caller_id=user_id, group_id=group_id, permission="write" ) assert row.gid == group_id # nosec - _check_group_permissions(row, user_id, group_id, "write") + # NOTE: update does not include access-rights 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)) + .where((groups.c.gid == group_id) & (groups.c.type == GroupType.STANDARD)) .returning(*_GROUP_COLUMNS) ) row = await result.fetchone() @@ -376,15 +392,14 @@ async def delete_standard_group( 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 + await _get_group_and_access_rights_or_raise( + conn, caller_id=user_id, group_id=group_id, permission="delete" ) - _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) + (groups.c.gid == group_id) & (groups.c.type == GroupType.STANDARD) ) ) @@ -398,7 +413,7 @@ async def get_user_from_email( app: web.Application, connection: AsyncConnection | None = None, *, - caller_user_id: UserID, + caller_id: UserID, email: str, ) -> Row: """ @@ -410,10 +425,7 @@ async def get_user_from_email( 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) - ) + & is_public(users.c.privacy_hide_email, caller_id=caller_id) ) ) user = await result.fetchone() @@ -427,30 +439,28 @@ async def get_user_from_email( # -def _group_user_cols(caller_user_id: int): +def _group_user_cols(caller_id: UserID): return ( users.c.id, users.c.name, # privacy settings sa.case( ( - users.c.privacy_hide_email.is_(True) & (users.c.id != caller_user_id), + is_private(users.c.privacy_hide_email, caller_id), None, ), else_=users.c.email, ).label("email"), sa.case( ( - users.c.privacy_hide_fullname.is_(True) - & (users.c.id != caller_user_id), + is_private(users.c.privacy_hide_fullname, caller_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), + is_private(users.c.privacy_hide_fullname, caller_id), None, ), else_=users.c.last_name, @@ -459,16 +469,19 @@ def _group_user_cols(caller_user_id: int): ) -async def _get_user_in_group( - conn: AsyncConnection, *, caller_user_id, group_id: GroupID, user_id: int +async def _get_user_in_group_or_raise( + conn: AsyncConnection, *, caller_id: UserID, group_id: GroupID, user_id: UserID ) -> Row: - # now get the user + # NOTE: that the caller_id might be different that the target user_id result = await conn.stream( - sa.select(*_group_user_cols(caller_user_id), user_to_groups.c.access_rights) + sa.select( + *_group_user_cols(caller_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)) + .where((user_to_groups.c.gid == group_id) & (users.c.id == user_id)) ) row = await result.fetchone() if not row: @@ -480,49 +493,82 @@ async def list_users_in_group( app: web.Application, connection: AsyncConnection | None = None, *, - user_id: UserID, + caller_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 + # GET GROUP & caller access-rights (if non PRIMARY) query = ( sa.select( - *_group_user_cols(user_id), + *_GROUP_COLUMNS, user_to_groups.c.access_rights, ) - .select_from(users.join(user_to_groups)) - .where(user_to_groups.c.gid == group_id) + .select_from( + groups.join( + user_to_groups, user_to_groups.c.gid == groups.c.gid, isouter=True + ).join(users, users.c.id == user_to_groups.c.uid) + ) + .where( + (user_to_groups.c.gid == group_id) + & ( + (user_to_groups.c.uid == caller_id) + | ( + (groups.c.type == GroupType.PRIMARY) + & users.c.role.in_([r for r in UserRole if r > UserRole.GUEST]) + ) + ) + ) ) - result = await conn.stream(query) - return [GroupMember.model_validate(row) async for row in result] + result = await conn.execute(query) + group_row = result.first() + if not group_row: + raise GroupNotFoundError(gid=group_id) + + # Drop access-rights if primary group + if group_row.type == GroupType.PRIMARY: + query = sa.select( + *_group_user_cols(caller_id), + ) + else: + _check_group_permissions( + group_row, caller_id=caller_id, group_id=group_id, permission="read" + ) + query = sa.select( + *_group_user_cols(caller_id), + user_to_groups.c.access_rights, + ) + + # GET users + query = query.select_from(users.join(user_to_groups, isouter=True)).where( + user_to_groups.c.gid == group_id + ) + + aresult = await conn.stream(query) + return [ + GroupMember.model_validate(row, from_attributes=True) + async for row in aresult + ] async def get_user_in_group( app: web.Application, connection: AsyncConnection | None = None, *, - user_id: UserID, + caller_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 + await _get_group_and_access_rights_or_raise( + conn, caller_id=caller_id, group_id=group_id, permission="read" ) - _check_group_permissions(group, user_id, group_id, "read") # get the user with its permissions - the_user = await _get_user_in_group( + the_user = await _get_user_in_group_or_raise( conn, - caller_user_id=user_id, + caller_id=caller_id, group_id=group_id, user_id=the_user_id_in_group, ) @@ -533,7 +579,7 @@ async def update_user_in_group( app: web.Application, connection: AsyncConnection | None = None, *, - user_id: UserID, + caller_id: UserID, group_id: GroupID, the_user_id_in_group: UserID, access_rights: AccessRightsDict, @@ -545,15 +591,14 @@ async def update_user_in_group( 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 + await _get_group_and_access_rights_or_raise( + conn, caller_id=caller_id, group_id=group_id, permission="write" ) - _check_group_permissions(group, user_id, group_id, "write") # now check the user exists - the_user = await _get_user_in_group( + the_user = await _get_user_in_group_or_raise( conn, - caller_user_id=user_id, + caller_id=caller_id, group_id=group_id, user_id=the_user_id_in_group, ) @@ -580,21 +625,20 @@ async def delete_user_from_group( app: web.Application, connection: AsyncConnection | None = None, *, - user_id: UserID, + caller_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 + await _get_group_and_access_rights_or_raise( + conn, caller_id=caller_id, group_id=group_id, permission="write" ) - _check_group_permissions(group, user_id, group_id, "write") # check the user exists - await _get_user_in_group( + await _get_user_in_group_or_raise( conn, - caller_user_id=user_id, + caller_id=caller_id, group_id=group_id, user_id=the_user_id_in_group, ) @@ -638,7 +682,7 @@ async def add_new_user_in_group( app: web.Application, connection: AsyncConnection | None = None, *, - user_id: UserID, + caller_id: UserID, group_id: GroupID, # either user_id or user_name new_user_id: UserID | None = None, @@ -650,10 +694,9 @@ async def add_new_user_in_group( """ 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 + await _get_group_and_access_rights_or_raise( + conn, caller_id=caller_id, group_id=group_id, permission="write" ) - _check_group_permissions(group, user_id, group_id, "write") query = sa.select(users.c.id) if new_user_id is not None: @@ -678,20 +721,23 @@ async def add_new_user_in_group( 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 + uid=new_user_id, gid=group_id, access_rights=user_access_rights ) ) except UniqueViolation as exc: raise UserAlreadyInGroupError( uid=new_user_id, gid=group_id, - user_id=user_id, + user_id=caller_id, access_rights=access_rights, ) from exc async def auto_add_user_to_groups( - app: web.Application, connection: AsyncConnection | None = None, *, user: dict + app: web.Application, + connection: AsyncConnection | None = None, + *, + user: dict, ) -> None: user_id: UserID = user["id"] diff --git a/services/web/server/src/simcore_service_webserver/groups/_groups_rest.py b/services/web/server/src/simcore_service_webserver/groups/_groups_rest.py index 3f5f778a7bc..32b5e507382 100644 --- a/services/web/server/src/simcore_service_webserver/groups/_groups_rest.py +++ b/services/web/server/src/simcore_service_webserver/groups/_groups_rest.py @@ -164,7 +164,7 @@ async def delete_group(request: web.Request): @permission_required("groups.*") @handle_plugin_requests_exceptions async def get_all_group_users(request: web.Request): - """Gets users in organization groups""" + """Gets users in organization or primary groups""" req_ctx = GroupsRequestContext.model_validate(request) path_params = parse_request_path_parameters_as(GroupsPathParams, request) diff --git a/services/web/server/src/simcore_service_webserver/groups/_groups_service.py b/services/web/server/src/simcore_service_webserver/groups/_groups_service.py index 9bb5587759b..f53a7be17c6 100644 --- a/services/web/server/src/simcore_service_webserver/groups/_groups_service.py +++ b/services/web/server/src/simcore_service_webserver/groups/_groups_service.py @@ -158,7 +158,7 @@ async def list_group_members( app: web.Application, user_id: UserID, group_id: GroupID ) -> list[GroupMember]: return await _groups_repository.list_users_in_group( - app, user_id=user_id, group_id=group_id + app, caller_id=user_id, group_id=group_id ) @@ -171,7 +171,7 @@ async def get_group_member( return await _groups_repository.get_user_in_group( app, - user_id=user_id, + caller_id=user_id, group_id=group_id, the_user_id_in_group=the_user_id_in_group, ) @@ -186,7 +186,7 @@ async def update_group_member( ) -> GroupMember: return await _groups_repository.update_user_in_group( app, - user_id=user_id, + caller_id=user_id, group_id=group_id, the_user_id_in_group=the_user_id_in_group, access_rights=access_rights, @@ -201,7 +201,7 @@ async def delete_group_member( ) -> None: return await _groups_repository.delete_user_from_group( app, - user_id=user_id, + caller_id=user_id, group_id=group_id, the_user_id_in_group=the_user_id_in_group, ) @@ -261,13 +261,13 @@ async def add_user_in_group( if new_by_user_email: user = await _groups_repository.get_user_from_email( - app, email=new_by_user_email, caller_user_id=user_id + app, email=new_by_user_email, caller_id=user_id ) new_by_user_id = user.id return await _groups_repository.add_new_user_in_group( app, - user_id=user_id, + caller_id=user_id, group_id=group_id, new_user_id=new_by_user_id, new_user_name=new_by_user_name, diff --git a/services/web/server/src/simcore_service_webserver/projects/_db_utils.py b/services/web/server/src/simcore_service_webserver/projects/_db_utils.py index e36e2d455b3..2b14c2d1566 100644 --- a/services/web/server/src/simcore_service_webserver/projects/_db_utils.py +++ b/services/web/server/src/simcore_service_webserver/projects/_db_utils.py @@ -151,7 +151,7 @@ async def _get_user_primary_group_gid(conn: SAConnection, user_id: int) -> int: sa.select(users.c.primary_gid).where(users.c.id == str(user_id)) ) if not primary_gid: - raise UserNotFoundError(uid=user_id) + raise UserNotFoundError(user_id=user_id) assert isinstance(primary_gid, int) return primary_gid diff --git a/services/web/server/src/simcore_service_webserver/security/_authz_access_roles.py b/services/web/server/src/simcore_service_webserver/security/_authz_access_roles.py index da342e1b996..0bd7e6a75eb 100644 --- a/services/web/server/src/simcore_service_webserver/security/_authz_access_roles.py +++ b/services/web/server/src/simcore_service_webserver/security/_authz_access_roles.py @@ -83,6 +83,7 @@ class PermissionDict(TypedDict, total=False): "user.notifications.write", "user.profile.delete", "user.profile.update", + "user.read", "user.tokens.*", "wallets.*", "workspaces.*", @@ -103,7 +104,7 @@ class PermissionDict(TypedDict, total=False): can=[ "product.details.*", "product.invitations.create", - "user.users.*", + "admin.users.read", ], inherits=[UserRole.TESTER], ), diff --git a/services/web/server/src/simcore_service_webserver/users/_common/schemas.py b/services/web/server/src/simcore_service_webserver/users/_common/schemas.py index b4455abfa07..04946e21fcc 100644 --- a/services/web/server/src/simcore_service_webserver/users/_common/schemas.py +++ b/services/web/server/src/simcore_service_webserver/users/_common/schemas.py @@ -12,7 +12,7 @@ import pycountry from models_library.api_schemas_webserver._base import InputSchema -from models_library.api_schemas_webserver.users import UserGet +from models_library.api_schemas_webserver.users import UserForAdminGet from models_library.emails import LowerCaseEmailStr from models_library.users import UserID from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator @@ -109,4 +109,6 @@ def _pre_check_and_normalize_country(cls, v): # asserts field names are in sync -assert set(PreRegisteredUserGet.model_fields).issubset(UserGet.model_fields) # nosec +assert set(PreRegisteredUserGet.model_fields).issubset( + UserForAdminGet.model_fields +) # nosec diff --git a/services/web/server/src/simcore_service_webserver/users/_users_repository.py b/services/web/server/src/simcore_service_webserver/users/_users_repository.py index 4c536536950..e76196cb087 100644 --- a/services/web/server/src/simcore_service_webserver/users/_users_repository.py +++ b/services/web/server/src/simcore_service_webserver/users/_users_repository.py @@ -30,6 +30,8 @@ from simcore_postgres_database.utils_users import ( UsersRepo, generate_alternative_username, + is_private, + is_public, ) from sqlalchemy import delete from sqlalchemy.engine.row import Row @@ -49,7 +51,97 @@ def _parse_as_user(user_id: Any) -> UserID: try: return TypeAdapter(UserID).validate_python(user_id) except ValidationError as err: - raise UserNotFoundError(uid=user_id, user_id=user_id) from err + raise UserNotFoundError(user_id=user_id) from err + + +def _public_user_cols(caller_id: int): + return ( + # Fits PublicUser model + users.c.id.label("user_id"), + users.c.name.label("user_name"), + # privacy settings + sa.case( + ( + is_private(users.c.privacy_hide_email, caller_id), + None, + ), + else_=users.c.email, + ).label("email"), + sa.case( + ( + is_private(users.c.privacy_hide_fullname, caller_id), + None, + ), + else_=users.c.first_name, + ).label("first_name"), + sa.case( + ( + is_private(users.c.privacy_hide_fullname, caller_id), + None, + ), + else_=users.c.last_name, + ).label("last_name"), + users.c.primary_gid.label("group_id"), + ) + + +# +# PUBLIC User +# + + +async def get_public_user( + engine: AsyncEngine, + connection: AsyncConnection | None = None, + *, + caller_id: UserID, + user_id: UserID, +): + query = sa.select(*_public_user_cols(caller_id=caller_id)).where( + users.c.id == user_id + ) + + async with pass_or_acquire_connection(engine, connection) as conn: + result = await conn.execute(query) + user = result.first() + if not user: + raise UserNotFoundError(user_id=user_id) + return user + + +async def search_public_user( + engine: AsyncEngine, + connection: AsyncConnection | None = None, + *, + caller_id: UserID, + search_pattern: str, + limit: int, +) -> list: + + _pattern = f"%{search_pattern}%" + + query = ( + sa.select(*_public_user_cols(caller_id=caller_id)) + .where( + users.c.name.ilike(_pattern) + | ( + is_public(users.c.privacy_hide_email, caller_id) + & users.c.email.ilike(_pattern) + ) + | ( + is_public(users.c.privacy_hide_fullname, caller_id) + & ( + users.c.first_name.ilike(_pattern) + | users.c.last_name.ilike(_pattern) + ) + ) + ) + .limit(limit) + ) + + async with pass_or_acquire_connection(engine, connection) as conn: + result = await conn.stream(query) + return [got async for got in result] async def get_user_or_raise( @@ -65,15 +157,16 @@ async def get_user_or_raise( assert return_column_names is not None # nosec assert set(return_column_names).issubset(users.columns.keys()) # nosec + query = sa.select(*(users.columns[name] for name in return_column_names)).where( + users.c.id == user_id + ) + async with pass_or_acquire_connection(engine, connection) as conn: - result = await conn.stream( - sa.select(*(users.columns[name] for name in return_column_names)).where( - users.c.id == user_id - ) - ) - row = await result.first() + result = await conn.execute(query) + row = result.first() if row is None: - raise UserNotFoundError(uid=user_id) + raise UserNotFoundError(user_id=user_id) + user: dict[str, Any] = row._asdict() return user @@ -88,7 +181,7 @@ async def get_user_primary_group_id( ).where(users.c.id == user_id) ) if primary_gid is None: - raise UserNotFoundError(uid=user_id) + raise UserNotFoundError(user_id=user_id) return primary_gid @@ -100,7 +193,9 @@ async def get_users_ids_in_group( ) -> set[UserID]: async with pass_or_acquire_connection(engine, connection) as conn: result = await conn.stream( - sa.select(user_to_groups.c.uid).where(user_to_groups.c.gid == group_id) + sa.select( + user_to_groups.c.uid, + ).where(user_to_groups.c.gid == group_id) ) return {row.uid async for row in result} @@ -108,7 +203,9 @@ async def get_users_ids_in_group( async def get_user_id_from_pgid(app: web.Application, primary_gid: int) -> UserID: async with pass_or_acquire_connection(engine=get_asyncpg_engine(app)) as conn: user_id: UserID = await conn.scalar( - sa.select(users.c.id).where(users.c.primary_gid == primary_gid) + sa.select( + users.c.id, + ).where(users.c.primary_gid == primary_gid) ) return user_id @@ -128,7 +225,7 @@ async def get_user_fullname(app: web.Application, *, user_id: UserID) -> FullNam ) user = await result.first() if not user: - raise UserNotFoundError(uid=user_id) + raise UserNotFoundError(user_id=user_id) return FullNameDict( first_name=user.first_name, @@ -141,7 +238,10 @@ async def get_guest_user_ids_and_names( ) -> list[tuple[UserID, UserNameID]]: async with pass_or_acquire_connection(engine=get_asyncpg_engine(app)) as conn: result = await conn.stream( - sa.select(users.c.id, users.c.name).where(users.c.role == UserRole.GUEST) + sa.select( + users.c.id, + users.c.name, + ).where(users.c.role == UserRole.GUEST) ) return TypeAdapter(list[tuple[UserID, UserNameID]]).validate_python( @@ -157,10 +257,12 @@ async def get_user_role(app: web.Application, *, user_id: UserID) -> UserRole: async with pass_or_acquire_connection(engine=get_asyncpg_engine(app)) as conn: user_role = await conn.scalar( - sa.select(users.c.role).where(users.c.id == user_id) + sa.select( + users.c.role, + ).where(users.c.id == user_id) ) if user_role is None: - raise UserNotFoundError(uid=user_id) + raise UserNotFoundError(user_id=user_id) assert isinstance(user_role, UserRole) # nosec return user_role @@ -198,7 +300,9 @@ async def do_update_expired_users( async with transaction_context(engine, connection) as conn: result = await conn.stream( users.update() - .values(status=UserStatus.EXPIRED) + .values( + status=UserStatus.EXPIRED, + ) .where( (users.c.expires_at.is_not(None)) & (users.c.status == UserStatus.ACTIVE) @@ -218,7 +322,11 @@ async def update_user_status( ): async with transaction_context(engine, connection) as conn: await conn.execute( - users.update().values(status=new_status).where(users.c.id == user_id) + users.update() + .values( + status=new_status, + ) + .where(users.c.id == user_id) ) @@ -232,7 +340,9 @@ async def search_users_and_get_profile( users_alias = sa.alias(users, name="users_alias") invited_by = ( - sa.select(users_alias.c.name) + sa.select( + users_alias.c.name, + ) .where(users_pre_registration_details.c.created_by == users_alias.c.id) .label("invited_by") ) @@ -291,11 +401,19 @@ async def get_user_products( ) -> list[Row]: async with pass_or_acquire_connection(engine, connection) as conn: product_name_subq = ( - sa.select(products.c.name) + sa.select( + products.c.name, + ) .where(products.c.group_id == groups.c.gid) .label("product_name") ) - products_gis_subq = sa.select(products.c.group_id).distinct().subquery() + products_gis_subq = ( + sa.select( + products.c.group_id, + ) + .distinct() + .subquery() + ) query = ( sa.select( groups.c.gid, @@ -326,7 +444,9 @@ async def create_user_details( async with transaction_context(engine, connection) as conn: await conn.execute( sa.insert(users_pre_registration_details).values( - created_by=created_by, pre_email=email, **other_values + created_by=created_by, + pre_email=email, + **other_values, ) ) @@ -341,7 +461,7 @@ async def get_user_billing_details( async with pass_or_acquire_connection(engine, connection) as conn: query = UsersRepo.get_billing_details_query(user_id=user_id) result = await conn.execute(query) - row = result.fetchone() + row = result.first() if not row: raise BillingDetailsNotFoundError(user_id=user_id) return UserBillingDetails.model_validate(row) @@ -356,7 +476,7 @@ async def delete_user_by_id( .where(users.c.id == user_id) .returning(users.c.id) # Return the ID of the deleted row otherwise None ) - deleted_user = result.fetchone() + deleted_user = result.first() # If no row was deleted, the user did not exist return bool(deleted_user) @@ -397,7 +517,7 @@ async def get_my_profile(app: web.Application, *, user_id: UserID) -> MyProfile: ) row = await result.first() if not row: - raise UserNotFoundError(uid=user_id) + raise UserNotFoundError(user_id=user_id) my_profile = MyProfile.model_validate(row, from_attributes=True) assert my_profile.id == user_id # nosec diff --git a/services/web/server/src/simcore_service_webserver/users/_users_rest.py b/services/web/server/src/simcore_service_webserver/users/_users_rest.py index 33540de2424..688b024b40a 100644 --- a/services/web/server/src/simcore_service_webserver/users/_users_rest.py +++ b/services/web/server/src/simcore_service_webserver/users/_users_rest.py @@ -5,7 +5,9 @@ from models_library.api_schemas_webserver.users import ( MyProfileGet, MyProfilePatch, - UsersSearchQueryParams, + UserGet, + UsersForAdminSearchQueryParams, + UsersSearch, ) from servicelib.aiohttp import status from servicelib.aiohttp.requests_validation import ( @@ -128,6 +130,32 @@ async def update_my_profile(request: web.Request) -> web.Response: return web.json_response(status=status.HTTP_204_NO_CONTENT) +# +# USERS (public) +# + + +@routes.post(f"/{API_VTAG}/users:search", name="search_users") +@login_required +@permission_required("user.read") +@_handle_users_exceptions +async def search_users(request: web.Request) -> web.Response: + req_ctx = UsersRequestContext.model_validate(request) + assert req_ctx.product_name # nosec + + # NOTE: Decided for body instead of query parameters because it is easier for the front-end + search_params = await parse_request_body_as(UsersSearch, request) + + found = await _users_service.search_public_users( + request.app, + caller_id=req_ctx.user_id, + match_=search_params.match_, + limit=search_params.limit, + ) + + return envelope_json_response([UserGet.from_model(user) for user in found]) + + # # USERS (only POs) # @@ -136,16 +164,16 @@ async def update_my_profile(request: web.Request) -> web.Response: _RESPONSE_MODEL_MINIMAL_POLICY["exclude_none"] = True -@routes.get(f"/{API_VTAG}/users:search", name="search_users") +@routes.get(f"/{API_VTAG}/admin/users:search", name="search_users_for_admin") @login_required -@permission_required("user.users.*") +@permission_required("admin.users.read") @_handle_users_exceptions -async def search_users(request: web.Request) -> web.Response: +async def search_users_for_admin(request: web.Request) -> web.Response: req_ctx = UsersRequestContext.model_validate(request) assert req_ctx.product_name # nosec - query_params: UsersSearchQueryParams = parse_request_query_parameters_as( - UsersSearchQueryParams, request + query_params: UsersForAdminSearchQueryParams = parse_request_query_parameters_as( + UsersForAdminSearchQueryParams, request ) found = await _users_service.search_users( @@ -157,11 +185,13 @@ async def search_users(request: web.Request) -> web.Response: ) -@routes.post(f"/{API_VTAG}/users:pre-register", name="pre_register_user") +@routes.post( + f"/{API_VTAG}/admin/users:pre-register", name="pre_register_user_for_admin" +) @login_required -@permission_required("user.users.*") +@permission_required("admin.users.read") @_handle_users_exceptions -async def pre_register_user(request: web.Request) -> web.Response: +async def pre_register_user_for_admin(request: web.Request) -> web.Response: req_ctx = UsersRequestContext.model_validate(request) pre_user_profile = await parse_request_body_as(PreRegisteredUserGet, request) diff --git a/services/web/server/src/simcore_service_webserver/users/_users_service.py b/services/web/server/src/simcore_service_webserver/users/_users_service.py index 289b4dd641e..2bb52b85d57 100644 --- a/services/web/server/src/simcore_service_webserver/users/_users_service.py +++ b/services/web/server/src/simcore_service_webserver/users/_users_service.py @@ -3,7 +3,7 @@ import pycountry from aiohttp import web -from models_library.api_schemas_webserver.users import MyProfilePatch, UserGet +from models_library.api_schemas_webserver.users import MyProfilePatch, UserForAdminGet from models_library.basic_types import IDStr from models_library.emails import LowerCaseEmailStr from models_library.groups import GroupID @@ -43,7 +43,7 @@ async def pre_register_user( app: web.Application, profile: PreRegisteredUserGet, creator_user_id: UserID, -) -> UserGet: +) -> UserForAdminGet: found = await search_users(app, email_glob=profile.email, include_products=False) if found: @@ -87,6 +87,25 @@ async def pre_register_user( # +async def get_public_user(app: web.Application, *, caller_id: UserID, user_id: UserID): + return await _users_repository.get_public_user( + get_asyncpg_engine(app), + caller_id=caller_id, + user_id=user_id, + ) + + +async def search_public_users( + app: web.Application, *, caller_id: UserID, match_: str, limit: int +) -> list: + return await _users_repository.search_public_user( + get_asyncpg_engine(app), + caller_id=caller_id, + search_pattern=match_, + limit=limit, + ) + + async def get_user(app: web.Application, user_id: UserID) -> dict[str, Any]: """ :raises UserNotFoundError: if missing but NOT if marked for deletion! @@ -108,7 +127,7 @@ async def get_user_id_from_gid(app: web.Application, primary_gid: GroupID) -> Us async def search_users( app: web.Application, email_glob: str, *, include_products: bool = False -) -> list[UserGet]: +) -> list[UserForAdminGet]: # NOTE: this search is deploy-wide i.e. independent of the product! def _glob_to_sql_like(glob_pattern: str) -> str: @@ -130,7 +149,7 @@ async def _list_products_or_none(user_id): return None return [ - UserGet( + UserForAdminGet( first_name=r.first_name or r.pre_first_name, last_name=r.last_name or r.pre_last_name, email=r.email or r.pre_email, 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 edb552a2958..9f1bb48ef0a 100644 --- a/services/web/server/src/simcore_service_webserver/users/exceptions.py +++ b/services/web/server/src/simcore_service_webserver/users/exceptions.py @@ -8,16 +8,18 @@ class UsersBaseError(WebServerBaseError): class UserNotFoundError(UsersBaseError): - def __init__(self, *, uid: int | None = None, email: str | None = None, **ctx: Any): + def __init__( + self, *, user_id: int | None = None, email: str | None = None, **ctx: Any + ): super().__init__( msg_template=( - "User id {uid} not found" - if uid + "User id {user_id} not found" + if user_id else f"User with email {email} not found" ), **ctx, ) - self.uid = uid + self.user_id = user_id self.email = email diff --git a/services/web/server/tests/unit/with_dbs/02/test_projects_groups_handlers.py b/services/web/server/tests/unit/with_dbs/02/test_projects_groups_handlers.py index 5af112ba78f..fe3ecfa8c3a 100644 --- a/services/web/server/tests/unit/with_dbs/02/test_projects_groups_handlers.py +++ b/services/web/server/tests/unit/with_dbs/02/test_projects_groups_handlers.py @@ -66,9 +66,9 @@ async def test_projects_groups_full_workflow( data, _ = await assert_status(resp, status.HTTP_200_OK) assert len(data) == 1 assert data[0]["gid"] == logged_user["primary_gid"] - assert data[0]["read"] == True - assert data[0]["write"] == True - assert data[0]["delete"] == True + assert data[0]["read"] is True + assert data[0]["write"] is True + assert data[0]["delete"] is True # Get project endpoint and check permissions url = client.app.router["get_project"].url_for( diff --git a/services/web/server/tests/unit/with_dbs/03/invitations/test_products__invitations_handlers.py b/services/web/server/tests/unit/with_dbs/03/invitations/test_products__invitations_handlers.py index 71da6536363..64aec0a93d9 100644 --- a/services/web/server/tests/unit/with_dbs/03/invitations/test_products__invitations_handlers.py +++ b/services/web/server/tests/unit/with_dbs/03/invitations/test_products__invitations_handlers.py @@ -153,7 +153,7 @@ async def test_pre_registration_and_invitation_workflow( ).model_dump() # Search user -> nothing - response = await client.get("/v0/users:search", params={"email": guest_email}) + response = await client.get("/v0/admin/users:search", params={"email": guest_email}) data, _ = await assert_status(response, expected_status) # i.e. no info of requester is found, i.e. needs pre-registration assert data == [] @@ -164,17 +164,21 @@ async def test_pre_registration_and_invitation_workflow( # assert response.status == status.HTTP_409_CONFLICT # Accept user for registration and create invitation for her - response = await client.post("/v0/users:pre-register", json=requester_info) + response = await client.post("/v0/admin/users:pre-register", json=requester_info) data, _ = await assert_status(response, expected_status) # Can only pre-register once for _ in range(MANY_TIMES): - response = await client.post("/v0/users:pre-register", json=requester_info) + response = await client.post( + "/v0/admin/users:pre-register", json=requester_info + ) await assert_status(response, status.HTTP_409_CONFLICT) # Search user again for _ in range(MANY_TIMES): - response = await client.get("/v0/users:search", params={"email": guest_email}) + response = await client.get( + "/v0/admin/users:search", params={"email": guest_email} + ) data, _ = await assert_status(response, expected_status) assert len(data) == 1 user_found = data[0] @@ -203,7 +207,7 @@ async def test_pre_registration_and_invitation_workflow( await assert_status(response, status.HTTP_200_OK) # find registered user - response = await client.get("/v0/users:search", params={"email": guest_email}) + response = await client.get("/v0/admin/users:search", params={"email": guest_email}) data, _ = await assert_status(response, expected_status) assert len(data) == 1 user_found = data[0] diff --git a/services/web/server/tests/unit/with_dbs/03/meta_modeling/test_meta_modeling_iterations.py b/services/web/server/tests/unit/with_dbs/03/meta_modeling/test_meta_modeling_iterations.py index a278e2e09e3..e00b67c0673 100644 --- a/services/web/server/tests/unit/with_dbs/03/meta_modeling/test_meta_modeling_iterations.py +++ b/services/web/server/tests/unit/with_dbs/03/meta_modeling/test_meta_modeling_iterations.py @@ -70,7 +70,9 @@ async def context_with_logged_user(client: TestClient, logged_user: UserInfoDict await conn.execute(projects.delete()) -@pytest.mark.skip(reason="TODO: temporary removed to check blocker") +@pytest.mark.skip( + reason="Blocking testing. Will follow up in https://github.com/ITISFoundation/osparc-simcore/issues/6976 " +) @pytest.mark.acceptance_test() async def test_iterators_workflow( client: TestClient, 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 cb45fc8d643..6b0ba408cc0 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 @@ -1,12 +1,14 @@ # pylint: disable=protected-access # pylint: disable=redefined-outer-name # pylint: disable=too-many-arguments +# pylint: disable=too-many-statements # pylint: disable=unused-argument # pylint: disable=unused-variable import functools import sys +from collections.abc import AsyncIterable from copy import deepcopy from http import HTTPStatus from typing import Any @@ -19,15 +21,21 @@ from common_library.users_enums import UserRole, UserStatus from faker import Faker from models_library.api_schemas_webserver.auth import AccountRequestInfo -from models_library.api_schemas_webserver.users import MyProfileGet, UserGet +from models_library.api_schemas_webserver.groups import GroupUserGet +from models_library.api_schemas_webserver.users import ( + MyProfileGet, + UserForAdminGet, + UserGet, +) from psycopg2 import OperationalError +from pydantic import TypeAdapter from pytest_simcore.helpers.assert_checks import assert_status from pytest_simcore.helpers.faker_factories import ( DEFAULT_TEST_PASSWORD, random_pre_registration_details, ) from pytest_simcore.helpers.monkeypatch_envs import EnvVarsDict, setenvs_from_dict -from pytest_simcore.helpers.webserver_login import UserInfoDict +from pytest_simcore.helpers.webserver_login import NewUser, UserInfoDict from servicelib.aiohttp import status from servicelib.rest_constants import RESPONSE_MODEL_POLICY from simcore_service_webserver.users._common.schemas import ( @@ -53,6 +61,188 @@ def app_environment( ) +@pytest.fixture +async def private_user(client: TestClient) -> AsyncIterable[UserInfoDict]: + assert client.app + async with NewUser( + app=client.app, + user_data={ + "name": "jamie01", + "first_name": "James", + "last_name": "Bond", + "email": "james@find.me", + "privacy_hide_email": True, + "privacy_hide_fullname": True, + }, + ) as usr: + yield usr + + +@pytest.fixture +async def semi_private_user(client: TestClient) -> AsyncIterable[UserInfoDict]: + assert client.app + async with NewUser( + app=client.app, + user_data={ + "name": "maxwell", + "first_name": "James", + "last_name": "Maxwell", + "email": "j@maxwell.me", + "privacy_hide_email": True, + "privacy_hide_fullname": False, # <-- + }, + ) as usr: + yield usr + + +@pytest.fixture +async def public_user(client: TestClient) -> AsyncIterable[UserInfoDict]: + assert client.app + async with NewUser( + app=client.app, + user_data={ + "name": "taylie01", + "first_name": "Taylor", + "last_name": "Swift", + "email": "taylor@find.me", + "privacy_hide_email": False, + "privacy_hide_fullname": False, + }, + ) as usr: + yield usr + + +@pytest.mark.acceptance_test( + "https://github.com/ITISFoundation/osparc-issues/issues/1779" +) +@pytest.mark.parametrize("user_role", [UserRole.USER]) +async def test_search_users( + logged_user: UserInfoDict, + client: TestClient, + user_role: UserRole, + public_user: UserInfoDict, + semi_private_user: UserInfoDict, + private_user: UserInfoDict, +): + assert client.app + assert user_role.value == logged_user["role"] + + assert private_user["id"] != logged_user["id"] + assert public_user["id"] != logged_user["id"] + + # SEARCH by partial first_name + partial_name = "james" + assert partial_name in private_user.get("first_name", "").lower() + assert partial_name in semi_private_user.get("first_name", "").lower() + + url = client.app.router["search_users"].url_for() + resp = await client.post(f"{url}", json={"match": partial_name}) + data, _ = await assert_status(resp, status.HTTP_200_OK) + + found = TypeAdapter(list[UserGet]).validate_python(data) + assert found + assert len(found) == 1 + assert semi_private_user["name"] == found[0].user_name + assert found[0].first_name == semi_private_user.get("first_name") + assert found[0].last_name == semi_private_user.get("last_name") + assert found[0].email is None + + # SEARCH by partial email + partial_email = "@find.m" + assert partial_email in private_user["email"] + assert partial_email in public_user["email"] + + url = client.app.router["search_users"].url_for() + resp = await client.post(f"{url}", json={"match": partial_email}) + data, _ = await assert_status(resp, status.HTTP_200_OK) + + found = TypeAdapter(list[UserGet]).validate_python(data) + assert found + assert len(found) == 1 + assert found[0].user_id == public_user["id"] + assert found[0].user_name == public_user["name"] + assert found[0].email == public_user["email"] + assert found[0].first_name == public_user.get("first_name") + assert found[0].last_name == public_user.get("last_name") + + # SEARCH by partial username + partial_username = "ie01" + assert partial_username in private_user["name"] + assert partial_username in public_user["name"] + + url = client.app.router["search_users"].url_for() + resp = await client.post(f"{url}", json={"match": partial_username}) + data, _ = await assert_status(resp, status.HTTP_200_OK) + + found = TypeAdapter(list[UserGet]).validate_python(data) + assert found + assert len(found) == 2 + + index = [u.user_id for u in found].index(public_user["id"]) + assert found[index].user_name == public_user["name"] + + # check privacy + index = (index + 1) % 2 + assert found[index].user_name == private_user["name"] + assert found[index].email is None + assert found[index].first_name is None + assert found[index].last_name is None + + # SEARCH user for admin (from a USER) + url = ( + client.app.router["search_users_for_admin"] + .url_for() + .with_query(email=partial_email) + ) + resp = await client.get(f"{url}") + await assert_status(resp, status.HTTP_403_FORBIDDEN) + + +@pytest.mark.acceptance_test( + "https://github.com/ITISFoundation/osparc-issues/issues/1779" +) +@pytest.mark.parametrize("user_role", [UserRole.USER]) +async def test_get_user_by_group_id( + logged_user: UserInfoDict, + client: TestClient, + user_role: UserRole, + public_user: UserInfoDict, + private_user: UserInfoDict, +): + assert client.app + assert user_role.value == logged_user["role"] + + assert private_user["id"] != logged_user["id"] + assert public_user["id"] != logged_user["id"] + + # GET user by primary GID + url = client.app.router["get_all_group_users"].url_for( + gid=f"{public_user['primary_gid']}" + ) + resp = await client.get(f"{url}") + data, _ = await assert_status(resp, status.HTTP_200_OK) + + users = TypeAdapter(list[GroupUserGet]).validate_python(data) + assert len(users) == 1 + assert users[0].id == public_user["id"] + assert users[0].user_name == public_user["name"] + assert users[0].first_name == public_user.get("first_name") + assert users[0].last_name == public_user.get("last_name") + + url = client.app.router["get_all_group_users"].url_for( + gid=f"{private_user['primary_gid']}" + ) + resp = await client.get(f"{url}") + data, _ = await assert_status(resp, status.HTTP_200_OK) + + users = TypeAdapter(list[GroupUserGet]).validate_python(data) + assert len(users) == 1 + assert users[0].id == private_user["id"] + assert users[0].user_name == private_user["name"] + assert users[0].first_name is None + assert users[0].last_name is None + + @pytest.mark.parametrize( "user_role,expected", [ @@ -345,8 +535,8 @@ async def test_access_rights_on_search_users_only_product_owners_can_access( ): assert client.app - url = client.app.router["search_users"].url_for() - assert url.path == "/v0/users:search" + url = client.app.router["search_users_for_admin"].url_for() + assert url.path == "/v0/admin/users:search" resp = await client.get(url.path, params={"email": "do-not-exists@foo.com"}) await assert_status(resp, expected) @@ -396,12 +586,14 @@ async def test_search_and_pre_registration( assert client.app # ONLY in `users` and NOT `users_pre_registration_details` - resp = await client.get("/v0/users:search", params={"email": logged_user["email"]}) + resp = await client.get( + "/v0/admin/users:search", params={"email": logged_user["email"]} + ) assert resp.status == status.HTTP_200_OK found, _ = await assert_status(resp, status.HTTP_200_OK) assert len(found) == 1 - got = UserGet( + got = UserForAdminGet( **found[0], institution=None, address=None, @@ -430,15 +622,15 @@ async def test_search_and_pre_registration( # NOT in `users` and ONLY `users_pre_registration_details` # create pre-registration - resp = await client.post("/v0/users:pre-register", json=account_request_form) + resp = await client.post("/v0/admin/users:pre-register", json=account_request_form) assert resp.status == status.HTTP_200_OK resp = await client.get( - "/v0/users:search", params={"email": account_request_form["email"]} + "/v0/admin/users:search", params={"email": account_request_form["email"]} ) found, _ = await assert_status(resp, status.HTTP_200_OK) assert len(found) == 1 - got = UserGet(**found[0], state=None, status=None) + got = UserForAdminGet(**found[0], state=None, status=None) assert got.model_dump(include={"registered", "status"}) == { "registered": False, @@ -457,11 +649,11 @@ async def test_search_and_pre_registration( ) resp = await client.get( - "/v0/users:search", params={"email": account_request_form["email"]} + "/v0/admin/users:search", params={"email": account_request_form["email"]} ) found, _ = await assert_status(resp, status.HTTP_200_OK) assert len(found) == 1 - got = UserGet(**found[0], state=None) + got = UserForAdminGet(**found[0], state=None) assert got.model_dump(include={"registered", "status"}) == { "registered": True, "status": new_user["status"].name,