diff --git a/.vscode/settings.template.json b/.vscode/settings.template.json index 2de40f80ad62..e2fcfd8b37bb 100644 --- a/.vscode/settings.template.json +++ b/.vscode/settings.template.json @@ -14,7 +14,7 @@ "**/requirements/*.txt": "pip-requirements", "*logs.txt": "log", "*Makefile": "makefile", - "*sql.*": "sql", + "*.sql": "sql", "docker-compose*.yml": "dockercompose", "Dockerfile*": "dockerfile" }, diff --git a/api/specs/web-server/_tags.py b/api/specs/web-server/_tags.py index 2cea934a7881..c1b768b0dc57 100644 --- a/api/specs/web-server/_tags.py +++ b/api/specs/web-server/_tags.py @@ -22,6 +22,7 @@ @router.post( "/tags", response_model=Envelope[TagGet], + status_code=status.HTTP_201_CREATED, ) async def create_tag(_body: TagCreate): ... diff --git a/api/specs/web-server/_tags_groups.py b/api/specs/web-server/_tags_groups.py index 832e553ad703..38dfbf401586 100644 --- a/api/specs/web-server/_tags_groups.py +++ b/api/specs/web-server/_tags_groups.py @@ -8,7 +8,9 @@ from fastapi import APIRouter, Depends, status from models_library.generics import Envelope +from models_library.rest_error import EnvelopedError from simcore_service_webserver._meta import API_VTAG +from simcore_service_webserver.tags._rest import _TO_HTTP_ERROR_MAP from simcore_service_webserver.tags.schemas import ( TagGet, TagGroupCreate, @@ -23,6 +25,9 @@ "tags", "groups", ], + responses={ + i.status_code: {"model": EnvelopedError} for i in _TO_HTTP_ERROR_MAP.values() + }, ) @@ -31,7 +36,7 @@ response_model=Envelope[list[TagGroupGet]], ) async def list_tag_groups(_path_params: Annotated[TagPathParams, Depends()]): - ... + """Lists all groups associated to this tag""" @router.post( @@ -42,17 +47,17 @@ async def list_tag_groups(_path_params: Annotated[TagPathParams, Depends()]): async def create_tag_group( _path_params: Annotated[TagGroupPathParams, Depends()], _body: TagGroupCreate ): - ... + """Shares tag `tag_id` with an organization or user with `group_id` providing access-rights to it""" @router.put( "/tags/{tag_id}/groups/{group_id}", response_model=Envelope[list[TagGroupGet]], ) -async def replace_tag_groups( +async def replace_tag_group( _path_params: Annotated[TagGroupPathParams, Depends()], _body: TagGroupCreate ): - ... + """Replace access rights on tag for associated organization or user with `group_id`""" @router.delete( @@ -60,4 +65,4 @@ async def replace_tag_groups( status_code=status.HTTP_204_NO_CONTENT, ) async def delete_tag_group(_path_params: Annotated[TagGroupPathParams, Depends()]): - ... + """Delete access rights on tag to an associated organization or user with `group_id`""" diff --git a/packages/common-library/src/common_library/groups_dicts.py b/packages/common-library/src/common_library/groups_dicts.py new file mode 100644 index 000000000000..f709eb2cdbf2 --- /dev/null +++ b/packages/common-library/src/common_library/groups_dicts.py @@ -0,0 +1,7 @@ +from typing_extensions import TypedDict + + +class AccessRightsDict(TypedDict): + read: bool + write: bool + delete: bool diff --git a/packages/postgres-database/src/simcore_postgres_database/utils_tags.py b/packages/postgres-database/src/simcore_postgres_database/utils_tags.py index 7421f25de0fc..14b283b71570 100644 --- a/packages/postgres-database/src/simcore_postgres_database/utils_tags.py +++ b/packages/postgres-database/src/simcore_postgres_database/utils_tags.py @@ -1,34 +1,39 @@ """ Repository pattern, errors and data structures for models.tags """ - -from typing import TypedDict - +from common_library.errors_classes import OsparcErrorMixin from sqlalchemy.ext.asyncio import AsyncConnection, AsyncEngine +from typing_extensions import TypedDict from .utils_repos import pass_or_acquire_connection, transaction_context from .utils_tags_sql import ( + TagAccessRightsDict, count_groups_with_given_access_rights_stmt, create_tag_stmt, + delete_tag_access_rights_stmt, delete_tag_stmt, get_tag_stmt, + has_access_rights_stmt, + list_tag_group_access_stmt, list_tags_stmt, - set_tag_access_rights_stmt, update_tag_stmt, + upsert_tags_access_rights_stmt, ) +__all__: tuple[str, ...] = ("TagAccessRightsDict",) + # # Errors # -class BaseTagError(Exception): - pass +class _BaseTagError(OsparcErrorMixin, Exception): + msg_template = "Tag repo error on tag {tag_id}" -class TagNotFoundError(BaseTagError): +class TagNotFoundError(_BaseTagError): pass -class TagOperationNotAllowedError(BaseTagError): # maps to AccessForbidden +class TagOperationNotAllowedError(_BaseTagError): # maps to AccessForbidden pass @@ -108,7 +113,7 @@ async def create( assert tag # nosec # take tag ownership - access_stmt = set_tag_access_rights_stmt( + access_stmt = upsert_tags_access_rights_stmt( tag_id=tag.id, user_id=user_id, read=read, @@ -163,8 +168,7 @@ async def get( result = await conn.execute(stmt_get) row = result.first() if not row: - msg = f"{tag_id=} not found: either no access or does not exists" - raise TagNotFoundError(msg) + raise TagNotFoundError(operation="get", tag_id=tag_id, user_id=user_id) return TagDict( id=row.id, name=row.name, @@ -198,8 +202,9 @@ async def update( result = await conn.execute(update_stmt) row = result.first() if not row: - msg = f"{tag_id=} not updated: either no access or not found" - raise TagOperationNotAllowedError(msg) + raise TagOperationNotAllowedError( + operation="update", tag_id=tag_id, user_id=user_id + ) return TagDict( id=row.id, @@ -222,44 +227,95 @@ async def delete( async with transaction_context(self.engine, connection) as conn: deleted = await conn.scalar(stmt_delete) if not deleted: - msg = f"Could not delete {tag_id=}. Not found or insuficient access." - raise TagOperationNotAllowedError(msg) + raise TagOperationNotAllowedError( + operation="delete", tag_id=tag_id, user_id=user_id + ) # # ACCESS RIGHTS # - async def create_access_rights( + async def has_access_rights( self, connection: AsyncConnection | None = None, *, user_id: int, tag_id: int, - group_id: int, - read: bool, - write: bool, - delete: bool, - ): - raise NotImplementedError + read: bool = False, + write: bool = False, + delete: bool = False, + ) -> bool: + async with pass_or_acquire_connection(self.engine, connection) as conn: + group_id_or_none = await conn.scalar( + has_access_rights_stmt( + tag_id=tag_id, + caller_user_id=user_id, + read=read, + write=write, + delete=delete, + ) + ) + return bool(group_id_or_none) - async def update_access_rights( + async def list_access_rights( + self, + connection: AsyncConnection | None = None, + *, + tag_id: int, + ) -> list[TagAccessRightsDict]: + async with pass_or_acquire_connection(self.engine, connection) as conn: + result = await conn.execute(list_tag_group_access_stmt(tag_id=tag_id)) + return [ + TagAccessRightsDict( + tag_id=row.tag_id, + group_id=row.group_id, + read=row.read, + write=row.write, + delete=row.delete, + ) + for row in result.fetchall() + ] + + async def create_or_update_access_rights( self, connection: AsyncConnection | None = None, *, - user_id: int, tag_id: int, group_id: int, read: bool, write: bool, delete: bool, - ): - raise NotImplementedError + ) -> TagAccessRightsDict: + async with transaction_context(self.engine, connection) as conn: + result = await conn.execute( + upsert_tags_access_rights_stmt( + tag_id=tag_id, + group_id=group_id, + read=read, + write=write, + delete=delete, + ) + ) + row = result.first() + assert row is not None + + return TagAccessRightsDict( + tag_id=row.tag_id, + group_id=row.group_id, + read=row.read, + write=row.write, + delete=row.delete, + ) async def delete_access_rights( self, connection: AsyncConnection | None = None, *, - user_id: int, tag_id: int, - ): - raise NotImplementedError + group_id: int, + ) -> bool: + async with transaction_context(self.engine, connection) as conn: + deleted: bool = await conn.scalar( + delete_tag_access_rights_stmt(tag_id=tag_id, group_id=group_id) + ) + return deleted diff --git a/packages/postgres-database/src/simcore_postgres_database/utils_tags_sql.py b/packages/postgres-database/src/simcore_postgres_database/utils_tags_sql.py index 072a6bd2d67c..d34b2fa8844c 100644 --- a/packages/postgres-database/src/simcore_postgres_database/utils_tags_sql.py +++ b/packages/postgres-database/src/simcore_postgres_database/utils_tags_sql.py @@ -9,6 +9,8 @@ from simcore_postgres_database.models.tags_access_rights import tags_access_rights from simcore_postgres_database.models.users import users from sqlalchemy.dialects.postgresql import insert as pg_insert +from sqlalchemy.sql.selectable import ScalarSelect +from typing_extensions import TypedDict _TAG_COLUMNS = [ tags.c.id, @@ -130,25 +132,6 @@ def count_groups_with_given_access_rights_stmt( return sa.select(sa.func.count(user_to_groups.c.uid)).select_from(j) -def set_tag_access_rights_stmt( - *, tag_id: int, user_id: int, read: bool, write: bool, delete: bool -): - scalar_subq = ( - sa.select(users.c.primary_gid).where(users.c.id == user_id).scalar_subquery() - ) - return ( - tags_access_rights.insert() - .values( - tag_id=tag_id, - group_id=scalar_subq, - read=read, - write=write, - delete=delete, - ) - .returning(*_ACCESS_RIGHTS_COLUMNS) - ) - - def update_tag_stmt(*, user_id: int, tag_id: int, **updates): return ( tags.update() @@ -182,6 +165,128 @@ def delete_tag_stmt(*, user_id: int, tag_id: int): ) +# +# ACCESS RIGHTS +# + +_TAG_ACCESS_RIGHTS_COLS = [ + tags_access_rights.c.tag_id, + tags_access_rights.c.group_id, + *_ACCESS_RIGHTS_COLUMNS, +] + + +class TagAccessRightsDict(TypedDict): + tag_id: int + group_id: int + # access rights + read: bool + write: bool + delete: bool + + +def has_access_rights_stmt( + *, + tag_id: int, + caller_user_id: int | None = None, + caller_group_id: int | None = None, + read: bool = False, + write: bool = False, + delete: bool = False, +): + conditions = [] + + # caller + if caller_user_id is not None: + group_condition = ( + tags_access_rights.c.group_id + == sa.select(users.c.primary_gid) + .where(users.c.id == caller_user_id) + .scalar_subquery() + ) + elif caller_group_id is not None: + group_condition = tags_access_rights.c.group_id == caller_group_id + else: + msg = "Either caller_user_id or caller_group_id must be provided." + raise ValueError(msg) + + conditions.append(group_condition) + + # access-right + if read: + conditions.append(tags_access_rights.c.read.is_(True)) + if write: + conditions.append(tags_access_rights.c.write.is_(True)) + if delete: + conditions.append(tags_access_rights.c.delete.is_(True)) + + return sa.select(tags_access_rights.c.group_id).where( + sa.and_( + tags_access_rights.c.tag_id == tag_id, + *conditions, + ) + ) + + +def list_tag_group_access_stmt(*, tag_id: int): + return sa.select(*_TAG_ACCESS_RIGHTS_COLS).where( + tags_access_rights.c.tag_id == tag_id + ) + + +def upsert_tags_access_rights_stmt( + *, + tag_id: int, + group_id: int | None = None, + user_id: int | None = None, + read: bool, + write: bool, + delete: bool, +): + assert not (user_id is None and group_id is None) # nosec + assert not (user_id is not None and group_id is not None) # nosec + + target_group_id: int | ScalarSelect + + if user_id: + assert not group_id # nosec + target_group_id = ( + sa.select(users.c.primary_gid) + .where(users.c.id == user_id) + .scalar_subquery() + ) + else: + assert group_id # nosec + target_group_id = group_id + + return ( + pg_insert(tags_access_rights) + .values( + tag_id=tag_id, + group_id=target_group_id, + read=read, + write=write, + delete=delete, + ) + .on_conflict_do_update( + index_elements=["tag_id", "group_id"], + set_={"read": read, "write": write, "delete": delete}, + ) + .returning(*_TAG_ACCESS_RIGHTS_COLS) + ) + + +def delete_tag_access_rights_stmt(*, tag_id: int, group_id: int): + return ( + sa.delete(tags_access_rights) + .where( + (tags_access_rights.c.tag_id == tag_id) + & (tags_access_rights.c.group_id == group_id) + ) + .returning(tags_access_rights.c.tag_id.is_not(None)) + ) + + # # PROJECT TAGS # diff --git a/packages/postgres-database/tests/test_utils_tags.py b/packages/postgres-database/tests/test_utils_tags.py index 1f7f882da0a7..e8a8fee9df4b 100644 --- a/packages/postgres-database/tests/test_utils_tags.py +++ b/packages/postgres-database/tests/test_utils_tags.py @@ -28,8 +28,8 @@ get_tags_for_project_stmt, get_tags_for_services_stmt, list_tags_stmt, - set_tag_access_rights_stmt, update_tag_stmt, + upsert_tags_access_rights_stmt, ) from sqlalchemy.ext.asyncio import AsyncEngine @@ -630,9 +630,6 @@ async def test_tags_repo_create( name="T1", description="my first tag", color="pink", - read=True, - write=True, - delete=True, ) assert tag_1 == { "id": 1, @@ -654,6 +651,108 @@ async def test_tags_repo_create( == user.primary_gid ) + # Checks defaults to full ownership + assert await tags_repo.has_access_rights( + user_id=user.id, + tag_id=tag_1["id"], + read=True, + write=True, + delete=True, + ) + + +async def test_tags_repo_access_rights( + asyncpg_engine: AsyncEngine, + user: RowProxy, + group: RowProxy, + other_user: RowProxy, +): + tags_repo = TagsRepo(asyncpg_engine) + tag = await tags_repo.create( + user_id=user.id, + name="T1", + description="my first tag", + color="pink", + ) + + # check ownership + tag_accesses = await tags_repo.list_access_rights(tag_id=tag["id"]) + assert len(tag_accesses) == 1 + user_access = tag_accesses[0] + assert user_access == { + "group_id": user.primary_gid, + "tag_id": tag["id"], + "read": True, + "write": True, + "delete": True, + } + + assert await tags_repo.has_access_rights( + user_id=user.id, + tag_id=tag["id"], + read=True, + write=True, + delete=True, + ) + + # CREATE access for other_user + other_user_access = await tags_repo.create_or_update_access_rights( + tag_id=tag["id"], + group_id=other_user.primary_gid, + read=True, + write=False, + delete=False, + ) + + assert not await tags_repo.has_access_rights( + user_id=other_user.id, + tag_id=tag["id"], + read=user_access["read"], + write=user_access["write"], + delete=user_access["delete"], + ) + + assert await tags_repo.has_access_rights( + user_id=other_user.id, + tag_id=tag["id"], + read=other_user_access["read"], + write=other_user_access["write"], + delete=other_user_access["delete"], + ) + + tag_accesses = await tags_repo.list_access_rights(tag_id=tag["id"]) + assert len(tag_accesses) == 2 + + # UPDATE access + updated_access = await tags_repo.create_or_update_access_rights( + tag_id=tag["id"], + group_id=other_user.primary_gid, + read=False, # <-- + write=False, + delete=False, + ) + assert updated_access != other_user_access + + # checks partial + assert await tags_repo.has_access_rights( + user_id=other_user.id, + tag_id=tag["id"], + read=False, + ) + + assert not await tags_repo.has_access_rights( + user_id=other_user.id, tag_id=tag["id"], write=True + ) + + # DELETE access to other-user + await tags_repo.delete_access_rights( + tag_id=tag["id"], + group_id=other_user.primary_gid, + ) + + tag_accesses = await tags_repo.list_access_rights(tag_id=tag["id"]) + assert len(tag_accesses) == 1 + def test_building_tags_sql_statements(): def _check(func_smt, **kwargs): @@ -690,7 +789,7 @@ def _check(func_smt, **kwargs): ) _check( - set_tag_access_rights_stmt, + upsert_tags_access_rights_stmt, tag_id=tag_id, user_id=user_id, read=True, 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 2012853da80a..ca1c7b5be676 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 @@ -867,7 +867,7 @@ paths: $ref: '#/components/schemas/TagCreate' required: true responses: - '200': + '201': description: Successful Response content: application/json: @@ -924,6 +924,7 @@ paths: - tags - groups summary: List Tag Groups + description: Lists all groups associated to this tag operationId: list_tag_groups parameters: - name: tag_id @@ -941,12 +942,26 @@ paths: application/json: schema: $ref: '#/components/schemas/Envelope_list_TagGroupGet__' + '404': + content: + application/json: + schema: + $ref: '#/components/schemas/EnvelopedError' + description: Not Found + '403': + content: + application/json: + schema: + $ref: '#/components/schemas/EnvelopedError' + description: Forbidden /v0/tags/{tag_id}/groups/{group_id}: post: tags: - tags - groups summary: Create Tag Group + description: Shares tag `tag_id` with an organization or user with `group_id` + providing access-rights to it operationId: create_tag_group parameters: - name: tag_id @@ -978,12 +993,26 @@ paths: application/json: schema: $ref: '#/components/schemas/Envelope_TagGet_' + '404': + content: + application/json: + schema: + $ref: '#/components/schemas/EnvelopedError' + description: Not Found + '403': + content: + application/json: + schema: + $ref: '#/components/schemas/EnvelopedError' + description: Forbidden put: tags: - tags - groups - summary: Replace Tag Groups - operationId: replace_tag_groups + summary: Replace Tag Group + description: Replace access rights on tag for associated organization or user + with `group_id` + operationId: replace_tag_group parameters: - name: tag_id in: path @@ -1014,11 +1043,25 @@ paths: application/json: schema: $ref: '#/components/schemas/Envelope_list_TagGroupGet__' + '404': + content: + application/json: + schema: + $ref: '#/components/schemas/EnvelopedError' + description: Not Found + '403': + content: + application/json: + schema: + $ref: '#/components/schemas/EnvelopedError' + description: Forbidden delete: tags: - tags - groups summary: Delete Tag Group + description: Delete access rights on tag to an associated organization or user + with `group_id` operationId: delete_tag_group parameters: - name: tag_id @@ -1040,6 +1083,18 @@ paths: responses: '204': description: Successful Response + '404': + content: + application/json: + schema: + $ref: '#/components/schemas/EnvelopedError' + description: Not Found + '403': + content: + application/json: + schema: + $ref: '#/components/schemas/EnvelopedError' + description: Forbidden /v0/credits-price: get: tags: @@ -14177,22 +14232,12 @@ components: delete: type: boolean title: Delete - created: - type: string - format: date-time - title: Created - modified: - type: string - format: date-time - title: Modified type: object required: - gid - read - write - delete - - created - - modified title: TagGroupGet TagUpdate: properties: diff --git a/services/web/server/src/simcore_service_webserver/tags/_api.py b/services/web/server/src/simcore_service_webserver/tags/_api.py deleted file mode 100644 index dacedc603f79..000000000000 --- a/services/web/server/src/simcore_service_webserver/tags/_api.py +++ /dev/null @@ -1,58 +0,0 @@ -""" _api: implements `tags` plugin **service layer** -""" - -from aiohttp import web -from models_library.basic_types import IdInt -from models_library.users import UserID -from servicelib.aiohttp.db_asyncpg_engine import get_async_engine -from simcore_postgres_database.utils_tags import TagsRepo -from sqlalchemy.ext.asyncio import AsyncEngine - -from .schemas import TagCreate, TagGet, TagUpdate - - -async def create_tag( - app: web.Application, user_id: UserID, new_tag: TagCreate -) -> TagGet: - engine: AsyncEngine = get_async_engine(app) - - repo = TagsRepo(engine) - tag = await repo.create( - user_id=user_id, - read=True, - write=True, - delete=True, - **new_tag.model_dump(exclude_unset=True), - ) - return TagGet.from_db(tag) - - -async def list_tags( - app: web.Application, - user_id: UserID, -) -> list[TagGet]: - engine: AsyncEngine = get_async_engine(app) - repo = TagsRepo(engine) - tags = await repo.list_all(user_id=user_id) - return [TagGet.from_db(t) for t in tags] - - -async def update_tag( - app: web.Application, user_id: UserID, tag_id: IdInt, tag_updates: TagUpdate -) -> TagGet: - engine: AsyncEngine = get_async_engine(app) - - repo = TagsRepo(engine) - tag = await repo.update( - user_id=user_id, - tag_id=tag_id, - **tag_updates.model_dump(exclude_unset=True), - ) - return TagGet.from_db(tag) - - -async def delete_tag(app: web.Application, user_id: UserID, tag_id: IdInt): - engine: AsyncEngine = get_async_engine(app) - - repo = TagsRepo(engine) - await repo.delete(user_id=user_id, tag_id=tag_id) diff --git a/services/web/server/src/simcore_service_webserver/tags/_handlers.py b/services/web/server/src/simcore_service_webserver/tags/_rest.py similarity index 52% rename from services/web/server/src/simcore_service_webserver/tags/_handlers.py rename to services/web/server/src/simcore_service_webserver/tags/_rest.py index 24dff16d0666..ff812486e0bf 100644 --- a/services/web/server/src/simcore_service_webserver/tags/_handlers.py +++ b/services/web/server/src/simcore_service_webserver/tags/_rest.py @@ -1,24 +1,30 @@ -import functools - from aiohttp import web -from pydantic import TypeAdapter from servicelib.aiohttp import status from servicelib.aiohttp.requests_validation import ( parse_request_body_as, parse_request_path_parameters_as, ) -from servicelib.aiohttp.typing_extension import Handler -from servicelib.mimetype_constants import MIMETYPE_APPLICATION_JSON from simcore_postgres_database.utils_tags import ( TagNotFoundError, TagOperationNotAllowedError, ) +from simcore_service_webserver.tags.errors import ( + InsufficientTagShareAccessError, + ShareTagWithEveryoneNotAllowedError, + ShareTagWithProductGroupNotAllowedError, +) from .._meta import API_VTAG as VTAG +from ..exception_handling import ( + ExceptionToHttpErrorMap, + HttpErrorInfo, + exception_handling_decorator, + to_exceptions_handlers_map, +) from ..login.decorators import login_required from ..security.decorators import permission_required from ..utils_aiohttp import envelope_json_response -from . import _api +from . import _service from .schemas import ( TagCreate, TagGroupCreate, @@ -29,20 +35,33 @@ TagUpdate, ) - -def _handle_tags_exceptions(handler: Handler): - @functools.wraps(handler) - async def wrapper(request: web.Request) -> web.StreamResponse: - try: - return await handler(request) - - except TagNotFoundError as exc: - raise web.HTTPNotFound(reason=f"{exc}") from exc - - except TagOperationNotAllowedError as exc: - raise web.HTTPForbidden(reason=f"{exc}") from exc - - return wrapper +_TO_HTTP_ERROR_MAP: ExceptionToHttpErrorMap = { + TagNotFoundError: HttpErrorInfo( + status.HTTP_404_NOT_FOUND, + "Tag {tag_id} not found: either no access or does not exists", + ), + TagOperationNotAllowedError: HttpErrorInfo( + status.HTTP_403_FORBIDDEN, + "Could not {operation} tag {tag_id}. Not found or insuficient access.", + ), + ShareTagWithEveryoneNotAllowedError: HttpErrorInfo( + status.HTTP_403_FORBIDDEN, + "Sharing with everyone is not permitted.", + ), + ShareTagWithProductGroupNotAllowedError: HttpErrorInfo( + status.HTTP_403_FORBIDDEN, + "Sharing with all users is only permitted to admin users (e.g. testers, POs, ...).", + ), + InsufficientTagShareAccessError: HttpErrorInfo( + status.HTTP_403_FORBIDDEN, + "Insufficient access rightst to share (or unshare) tag {tag_id}.", + ), +} + + +_handle_tags_exceptions = exception_handling_decorator( + to_exceptions_handlers_map(_TO_HTTP_ERROR_MAP) +) routes = web.RouteTableDef() @@ -61,10 +80,10 @@ async def create_tag(request: web.Request): req_ctx = TagRequestContext.model_validate(request) new_tag = await parse_request_body_as(TagCreate, request) - created = await _api.create_tag( + created = await _service.create_tag( request.app, user_id=req_ctx.user_id, new_tag=new_tag ) - return envelope_json_response(created) + return envelope_json_response(created, status_cls=web.HTTPCreated) @routes.get(f"/{VTAG}/tags", name="list_tags") @@ -74,7 +93,7 @@ async def create_tag(request: web.Request): async def list_tags(request: web.Request): req_ctx = TagRequestContext.model_validate(request) - got = await _api.list_tags(request.app, user_id=req_ctx.user_id) + got = await _service.list_tags(request.app, user_id=req_ctx.user_id) return envelope_json_response(got) @@ -87,7 +106,7 @@ async def update_tag(request: web.Request): path_params = parse_request_path_parameters_as(TagPathParams, request) tag_updates = await parse_request_body_as(TagUpdate, request) - updated = await _api.update_tag( + updated = await _service.update_tag( request.app, user_id=req_ctx.user_id, tag_id=path_params.tag_id, @@ -104,7 +123,7 @@ async def delete_tag(request: web.Request): req_ctx = TagRequestContext.model_validate(request) path_params = parse_request_path_parameters_as(TagPathParams, request) - await _api.delete_tag( + await _service.delete_tag( request.app, user_id=req_ctx.user_id, tag_id=path_params.tag_id ) @@ -121,12 +140,15 @@ async def delete_tag(request: web.Request): @permission_required("tag.crud.*") @_handle_tags_exceptions async def list_tag_groups(request: web.Request): + req_ctx = TagRequestContext.model_validate(request) path_params = parse_request_path_parameters_as(TagPathParams, request) - assert path_params # nosec - assert envelope_json_response(TypeAdapter(list[TagGroupGet]).validate_python([])) - - raise NotImplementedError + got = await _service.list_tag_groups( + request.app, + caller_user_id=req_ctx.user_id, + tag_id=path_params.tag_id, + ) + return envelope_json_response([TagGroupGet.from_model(md) for md in got]) @routes.post(f"/{VTAG}/tags/{{tag_id}}/groups/{{group_id}}", name="create_tag_group") @@ -134,27 +156,41 @@ async def list_tag_groups(request: web.Request): @permission_required("tag.crud.*") @_handle_tags_exceptions async def create_tag_group(request: web.Request): + req_ctx = TagRequestContext.model_validate(request) path_params = parse_request_path_parameters_as(TagGroupPathParams, request) - new_tag_group = await parse_request_body_as(TagGroupCreate, request) + body_params = await parse_request_body_as(TagGroupCreate, request) - assert path_params # nosec - assert new_tag_group # nosec + got = await _service.share_tag_with_group( + request.app, + caller_user_id=req_ctx.user_id, + tag_id=path_params.tag_id, + group_id=path_params.group_id, + access_rights=body_params.to_model(), + ) - raise NotImplementedError + return envelope_json_response( + TagGroupGet.from_model(got), status_cls=web.HTTPCreated + ) -@routes.put(f"/{VTAG}/tags/{{tag_id}}/groups/{{group_id}}", name="replace_tag_groups") +@routes.put(f"/{VTAG}/tags/{{tag_id}}/groups/{{group_id}}", name="replace_tag_group") @login_required @permission_required("tag.crud.*") @_handle_tags_exceptions -async def replace_tag_groups(request: web.Request): +async def replace_tag_group(request: web.Request): + req_ctx = TagRequestContext.model_validate(request) path_params = parse_request_path_parameters_as(TagGroupPathParams, request) - new_tag_group = await parse_request_body_as(TagGroupCreate, request) + body_params = await parse_request_body_as(TagGroupCreate, request) - assert path_params # nosec - assert new_tag_group # nosec + got = await _service.share_tag_with_group( + request.app, + caller_user_id=req_ctx.user_id, + tag_id=path_params.tag_id, + group_id=path_params.group_id, + access_rights=body_params.to_model(), + ) - raise NotImplementedError + return envelope_json_response(TagGroupGet.from_model(got)) @routes.delete(f"/{VTAG}/tags/{{tag_id}}/groups/{{group_id}}", name="delete_tag_group") @@ -162,8 +198,14 @@ async def replace_tag_groups(request: web.Request): @permission_required("tag.crud.*") @_handle_tags_exceptions async def delete_tag_group(request: web.Request): + req_ctx = TagRequestContext.model_validate(request) path_params = parse_request_path_parameters_as(TagGroupPathParams, request) - assert path_params # nosec - assert web.HTTPNoContent(content_type=MIMETYPE_APPLICATION_JSON) - raise NotImplementedError + await _service.unshare_tag_with_group( + request.app, + caller_user_id=req_ctx.user_id, + tag_id=path_params.tag_id, + group_id=path_params.group_id, + ) + + return web.json_response(status=status.HTTP_204_NO_CONTENT) diff --git a/services/web/server/src/simcore_service_webserver/tags/_service.py b/services/web/server/src/simcore_service_webserver/tags/_service.py new file mode 100644 index 000000000000..36dcbdfcd46e --- /dev/null +++ b/services/web/server/src/simcore_service_webserver/tags/_service.py @@ -0,0 +1,181 @@ +""" Implements `tags` plugin **service layer** +""" + +from aiohttp import web +from common_library.groups_dicts import AccessRightsDict +from common_library.users_enums import UserRole +from models_library.basic_types import IdInt +from models_library.groups import EVERYONE_GROUP_ID, GroupID +from models_library.users import UserID +from servicelib.aiohttp.db_asyncpg_engine import get_async_engine +from simcore_postgres_database.utils_tags import TagAccessRightsDict, TagsRepo +from sqlalchemy.ext.asyncio import AsyncEngine + +from ..products.api import list_products +from ..users.api import get_user_role +from .errors import ( + InsufficientTagShareAccessError, + ShareTagWithEveryoneNotAllowedError, + ShareTagWithProductGroupNotAllowedError, +) +from .schemas import TagCreate, TagGet, TagUpdate + + +async def create_tag( + app: web.Application, user_id: UserID, new_tag: TagCreate +) -> TagGet: + """Creates tag and user_id takes ownership""" + engine: AsyncEngine = get_async_engine(app) + + repo = TagsRepo(engine) + tag = await repo.create( + user_id=user_id, + read=True, + write=True, + delete=True, + **new_tag.model_dump(exclude_unset=True), + ) + return TagGet.from_model(tag) + + +async def list_tags( + app: web.Application, + user_id: UserID, +) -> list[TagGet]: + engine: AsyncEngine = get_async_engine(app) + repo = TagsRepo(engine) + tags = await repo.list_all(user_id=user_id) + return [TagGet.from_model(t) for t in tags] + + +async def update_tag( + app: web.Application, user_id: UserID, tag_id: IdInt, tag_updates: TagUpdate +) -> TagGet: + engine: AsyncEngine = get_async_engine(app) + + repo = TagsRepo(engine) + tag = await repo.update( + user_id=user_id, + tag_id=tag_id, + **tag_updates.model_dump(exclude_unset=True), + ) + return TagGet.from_model(tag) + + +async def delete_tag(app: web.Application, user_id: UserID, tag_id: IdInt): + engine: AsyncEngine = get_async_engine(app) + + repo = TagsRepo(engine) + await repo.delete(user_id=user_id, tag_id=tag_id) + + +def _is_product_group(app: web.Application, group_id: GroupID): + products = list_products(app) + return any(group_id == p.group_id for p in products) + + +async def _validate_tag_sharing_permissions( + app: web.Application, + repo: TagsRepo, + *, + caller_user_id: UserID, + tag_id: IdInt, + group_id: GroupID, +) -> None: + """ + Raises: + ShareTagWithEveryoneNotAllowedError + ShareTagWithProductGroupNotAllowedError + InsufficientWriteAccessTagError + """ + if group_id == EVERYONE_GROUP_ID: + raise ShareTagWithEveryoneNotAllowedError( + user_id=caller_user_id, tag_id=tag_id, group_id=group_id + ) + + if _is_product_group(app, group_id=group_id): + user_role: UserRole = await get_user_role(app, user_id=caller_user_id) + if user_role < UserRole.TESTER: + raise ShareTagWithProductGroupNotAllowedError( + user_id=caller_user_id, + tag_id=tag_id, + group_id=group_id, + user_role=user_role, + ) + + if not await repo.has_access_rights( + user_id=caller_user_id, tag_id=tag_id, write=True + ): + raise InsufficientTagShareAccessError( + user_id=caller_user_id, tag_id=tag_id, group_id=group_id + ) + + +async def share_tag_with_group( + app: web.Application, + *, + caller_user_id: UserID, + tag_id: IdInt, + group_id: GroupID, + access_rights: AccessRightsDict, +) -> TagAccessRightsDict: + """ + Raises: + ShareTagWithEveryoneNotAllowedError + ShareTagWithProductGroupNotAllowedError + InsufficientWriteAccessTagError + TagOperationNotAllowedError + TagsValueError + """ + repo = TagsRepo(get_async_engine(app)) + + await _validate_tag_sharing_permissions( + app, repo, caller_user_id=caller_user_id, tag_id=tag_id, group_id=group_id + ) + + return await repo.create_or_update_access_rights( + tag_id=tag_id, + group_id=group_id, + **access_rights, + ) + + +async def unshare_tag_with_group( + app: web.Application, + *, + caller_user_id: UserID, + tag_id: IdInt, + group_id: GroupID, +) -> bool: + """ + Raises: + TagOperationNotAllowedError + + Returns: + True if unshared (NOTE: will not raise if not found) + """ + repo = TagsRepo(get_async_engine(app)) + + await _validate_tag_sharing_permissions( + app, repo, caller_user_id=caller_user_id, tag_id=tag_id, group_id=group_id + ) + + deleted: bool = await repo.delete_access_rights(tag_id=tag_id, group_id=group_id) + return deleted + + +async def list_tag_groups( + app: web.Application, + *, + caller_user_id: UserID, + tag_id: IdInt, +) -> list[TagAccessRightsDict]: + """Returns list of groups sharing this tag""" + repo = TagsRepo(get_async_engine(app)) + + if not await repo.has_access_rights( + user_id=caller_user_id, tag_id=tag_id, read=True + ): + return [] + + return await repo.list_access_rights(tag_id=tag_id) diff --git a/services/web/server/src/simcore_service_webserver/tags/errors.py b/services/web/server/src/simcore_service_webserver/tags/errors.py new file mode 100644 index 000000000000..95fa31859725 --- /dev/null +++ b/services/web/server/src/simcore_service_webserver/tags/errors.py @@ -0,0 +1,25 @@ +from ..errors import WebServerBaseError + + +class TagsPermissionError(WebServerBaseError, PermissionError): + ... + + +class ShareTagWithEveryoneNotAllowedError(TagsPermissionError): + msg_template = ( + "User {user_id} is not allowed to share (or unshare) tag {tag_id} with everyone" + ) + + +class ShareTagWithProductGroupNotAllowedError(TagsPermissionError): + msg_template = ( + "User {user_id} is not allowed to share (or unshare) tag {tag_id} with group {group_id}. " + "Only {user_role}>=TESTER users are allowed." + ) + + +class InsufficientTagShareAccessError(TagsPermissionError): + msg_template = ( + "User {user_id} does not have sufficient access rights to share" + " (or unshare) or unshare tag {tag_id}." + ) diff --git a/services/web/server/src/simcore_service_webserver/tags/plugin.py b/services/web/server/src/simcore_service_webserver/tags/plugin.py index b18a240a6179..650f8ba32976 100644 --- a/services/web/server/src/simcore_service_webserver/tags/plugin.py +++ b/services/web/server/src/simcore_service_webserver/tags/plugin.py @@ -7,7 +7,7 @@ from servicelib.aiohttp.application_keys import APP_SETTINGS_KEY from servicelib.aiohttp.application_setup import ModuleCategory, app_module_setup -from . import _handlers +from . import _rest _logger = logging.getLogger(__name__) @@ -21,4 +21,4 @@ ) def setup_tags(app: web.Application): assert app[APP_SETTINGS_KEY].WEBSERVER_TAGS # nosec - app.router.add_routes(_handlers.routes) + app.router.add_routes(_rest.routes) diff --git a/services/web/server/src/simcore_service_webserver/tags/schemas.py b/services/web/server/src/simcore_service_webserver/tags/schemas.py index 34ccce7248ae..95fdd8e717d1 100644 --- a/services/web/server/src/simcore_service_webserver/tags/schemas.py +++ b/services/web/server/src/simcore_service_webserver/tags/schemas.py @@ -1,14 +1,14 @@ import re -from datetime import datetime -from typing import Annotated +from typing import Annotated, Self +from common_library.groups_dicts import AccessRightsDict from models_library.api_schemas_webserver._base import InputSchema, OutputSchema from models_library.groups import GroupID from models_library.rest_base import RequestParameters, StrictRequestParameters from models_library.users import UserID from pydantic import Field, PositiveInt, StringConstraints from servicelib.request_keys import RQT_USERID_KEY -from simcore_postgres_database.utils_tags import TagDict +from simcore_postgres_database.utils_tags import TagAccessRightsDict, TagDict class TagRequestContext(RequestParameters): @@ -55,7 +55,7 @@ class TagGet(OutputSchema): access_rights: TagAccessRights = Field(..., alias="accessRights") @classmethod - def from_db(cls, tag: TagDict) -> "TagGet": + def from_model(cls, tag: TagDict) -> Self: # NOTE: cls(access_rights=tag, **tag) would also work because of Config return cls( id=tag["id"], @@ -84,6 +84,14 @@ class TagGroupCreate(InputSchema): write: bool delete: bool + def to_model(self) -> AccessRightsDict: + data = self.model_dump() + return AccessRightsDict( + read=data["read"], + write=data["write"], + delete=data["delete"], + ) + class TagGroupGet(OutputSchema): gid: GroupID @@ -91,6 +99,12 @@ class TagGroupGet(OutputSchema): read: bool write: bool delete: bool - # timestamps - created: datetime - modified: datetime + + @classmethod + def from_model(cls, data: TagAccessRightsDict) -> Self: + return cls( + gid=data["group_id"], + read=data["read"], + write=data["write"], + delete=data["delete"], + ) diff --git a/services/web/server/tests/integration/01/test_exporter_requests_handlers.py b/services/web/server/tests/integration/01/test_exporter_requests_handlers.py index 7aac08599008..4c0e271809d8 100644 --- a/services/web/server/tests/integration/01/test_exporter_requests_handlers.py +++ b/services/web/server/tests/integration/01/test_exporter_requests_handlers.py @@ -203,6 +203,8 @@ async def user(client: TestClient) -> AsyncIterable[UserInfoDict]: async def project( client: TestClient, template_path: Path, user: UserInfoDict, product_name: str ) -> AsyncIterable[ProjectDict]: + assert client.app + project = await _new_project( client=client, user=user, diff --git a/services/web/server/tests/unit/with_dbs/03/tags/test_tags.py b/services/web/server/tests/unit/with_dbs/03/tags/test_tags.py index eff7bc5c5326..0f70c98856c6 100644 --- a/services/web/server/tests/unit/with_dbs/03/tags/test_tags.py +++ b/services/web/server/tests/unit/with_dbs/03/tags/test_tags.py @@ -11,6 +11,9 @@ import sqlalchemy as sa from aiohttp.test_utils import TestClient from faker import Faker +from models_library.basic_types import IdInt +from models_library.groups import EVERYONE_GROUP_ID +from models_library.products import ProductName from models_library.projects_state import ( ProjectLocked, ProjectRunningState, @@ -21,12 +24,13 @@ from models_library.utils.fastapi_encoders import jsonable_encoder from pytest_simcore.helpers.assert_checks import assert_status from pytest_simcore.helpers.postgres_tags import create_tag, delete_tag -from pytest_simcore.helpers.webserver_login import UserInfoDict +from pytest_simcore.helpers.webserver_login import NewUser, UserInfoDict from pytest_simcore.helpers.webserver_projects import assert_get_same_project from servicelib.aiohttp import status from simcore_postgres_database.models.tags import tags from simcore_service_webserver.db.models import UserRole from simcore_service_webserver.db.plugin import get_database_engine +from simcore_service_webserver.products._api import get_product from simcore_service_webserver.projects.models import ProjectDict @@ -37,25 +41,17 @@ def _clean_tags_table(postgres_db: sa.engine.Engine) -> Iterator[None]: conn.execute(tags.delete()) -@pytest.fixture -def fake_tags(faker: Faker) -> list[dict[str, Any]]: - return [ - {"name": "tag1", "description": "description1", "color": "#f00"}, - {"name": "tag2", "description": "description2", "color": "#00f"}, - ] - - @pytest.fixture def user_role() -> UserRole: # All tests in test_tags assume USER's role - # i.e. Used in `logged_user` and `user_project` + # i.e. Used in `logged_user` and `user_project` fixtures return UserRole.USER async def test_tags_to_studies( client: TestClient, + faker: Faker, user_project: ProjectDict, - fake_tags: dict[str, Any], catalog_subsystem_mock: Callable[[list[ProjectDict]], None], ): catalog_subsystem_mock([user_project]) @@ -64,10 +60,13 @@ async def test_tags_to_studies( # Add test tags added_tags = [] - for tag in fake_tags: + for tag in [ + {"name": "tag1", "description": faker.sentence(), "color": "#f00"}, + {"name": "tag2", "description": faker.sentence(), "color": "#00f"}, + ]: url = client.app.router["create_tag"].url_for() resp = await client.post(f"{url}", json=tag) - added_tag, _ = await assert_status(resp, status.HTTP_200_OK) + added_tag, _ = await assert_status(resp, status.HTTP_201_CREATED) added_tags.append(added_tag) # Add tag to study @@ -151,8 +150,7 @@ async def test_read_tags( everybody_tag_id: int, ): assert client.app - - assert user_role == UserRole.USER + assert UserRole(logged_user["role"]) == user_role url = client.app.router["list_tags"].url_for() resp = await client.get(f"{url}") @@ -177,8 +175,7 @@ async def test_create_and_update_tags( _clean_tags_table: None, ): assert client.app - - assert user_role == UserRole.USER + assert UserRole(logged_user["role"]) == user_role # (1) create tag url = client.app.router["create_tag"].url_for() @@ -186,7 +183,7 @@ async def test_create_and_update_tags( f"{url}", json={"name": "T", "color": "#f00"}, ) - created, _ = await assert_status(resp, status.HTTP_200_OK) + created, _ = await assert_status(resp, status.HTTP_201_CREATED) assert created == { "id": created["id"], @@ -224,8 +221,7 @@ async def test_create_tags_with_order_index( _clean_tags_table: None, ): assert client.app - - assert user_role == UserRole.USER + assert UserRole(logged_user["role"]) == user_role # (1) create tags but set the order in reverse order of creation url = client.app.router["create_tag"].url_for() @@ -241,7 +237,7 @@ async def test_create_tags_with_order_index( "priority": priority_index, }, ) - created, _ = await assert_status(resp, status.HTTP_200_OK) + created, _ = await assert_status(resp, status.HTTP_201_CREATED) expected_tags[priority_index] = created url = client.app.router["list_tags"].url_for() @@ -266,13 +262,206 @@ async def test_create_tags_with_order_index( assert got == expected_tags[::-1] # (3) new tag without priority should get last (because is last created) + url = client.app.router["create_tag"].url_for() resp = await client.post( f"{url}", json={"name": "New", "color": "#f00", "description": "w/o priority"}, ) - last_created, _ = await assert_status(resp, status.HTTP_200_OK) + last_created, _ = await assert_status(resp, status.HTTP_201_CREATED) url = client.app.router["list_tags"].url_for() resp = await client.get(f"{url}") got, _ = await assert_status(resp, status.HTTP_200_OK) assert got == [*expected_tags[::-1], last_created] + + +async def test_share_tags_by_creating_associated_groups( + client: TestClient, + logged_user: UserInfoDict, + user_role: UserRole, + _clean_tags_table: None, +): + assert client.app + assert UserRole(logged_user["role"]) == user_role + + # CREATE + url = client.app.router["create_tag"].url_for() + resp = await client.post( + f"{url}", + json={"name": "shared", "color": "#fff"}, + ) + tag, _ = await assert_status(resp, status.HTTP_201_CREATED) + + # LIST + url = client.app.router["list_tag_groups"].url_for(tag_id=f"{tag['id']}") + resp = await client.get(f"{url}") + data, _ = await assert_status(resp, status.HTTP_200_OK) + + # check ownership + assert len(data) == 1 + assert data[0]["gid"] == logged_user["primary_gid"] + assert data[0]["read"] is True + assert data[0]["write"] is True + assert data[0]["delete"] is True + + async with NewUser( + app=client.app, + ) as new_user: + # CREATE SHARE + url = client.app.router["create_tag_group"].url_for( + tag_id=f"{tag['id']}", + group_id=f"{new_user['primary_gid']}", + ) + resp = await client.post( + f"{url}", + json={"read": True, "write": False, "delete": False}, + ) + data, _ = await assert_status(resp, status.HTTP_201_CREATED) + assert data["gid"] == new_user["primary_gid"] + + # check can read + url = client.app.router["list_tag_groups"].url_for(tag_id=f"{tag['id']}") + resp = await client.get(f"{url}") + data, _ = await assert_status(resp, status.HTTP_200_OK) + assert len(data) == 2 + assert data[1]["gid"] == new_user["primary_gid"] + assert data[1]["read"] is True + assert data[1]["write"] is False + assert data[1]["delete"] is False + + # REPLACE SHARE + url = client.app.router["replace_tag_group"].url_for( + tag_id=f"{tag['id']}", + group_id=f"{new_user['primary_gid']}", + ) + resp = await client.put( + f"{url}", + json={"read": True, "write": True, "delete": False}, + ) + data, _ = await assert_status(resp, status.HTTP_200_OK) + + # test can perform new combinations + assert data["gid"] == new_user["primary_gid"] + + url = client.app.router["list_tag_groups"].url_for(tag_id=f"{tag['id']}") + resp = await client.get(f"{url}") + data, _ = await assert_status(resp, status.HTTP_200_OK) + assert len(data) == 2 + assert data[1]["gid"] == new_user["primary_gid"] + assert data[1]["read"] is True + assert data[1]["write"] is True + assert data[1]["delete"] is False + + # DELETE SHARE + url = client.app.router["delete_tag_group"].url_for( + tag_id=f"{tag['id']}", + group_id=f"{new_user['primary_gid']}", + ) + resp = await client.delete( + f"{url}", + ) + await assert_status(resp, status.HTTP_204_NO_CONTENT) + + +@pytest.fixture +async def user_tag_id(client: TestClient) -> IdInt: + assert client.app + + url = client.app.router["create_tag"].url_for() + resp = await client.post( + f"{url}", + json={"name": "shared", "color": "#fff"}, + ) + tag, _ = await assert_status(resp, status.HTTP_201_CREATED) + return tag["id"] + + +@pytest.mark.parametrize( + "user_role", [role for role in UserRole if role >= UserRole.USER] +) +async def test_cannot_share_tag_with_everyone( + client: TestClient, + logged_user: UserInfoDict, + user_role: UserRole, + user_tag_id: IdInt, + _clean_tags_table: None, +): + assert client.app + assert UserRole(logged_user["role"]) == user_role + + # cannot SHARE with everyone group + url = client.app.router["create_tag_group"].url_for( + tag_id=f"{user_tag_id}", group_id=f"{EVERYONE_GROUP_ID}" + ) + resp = await client.post( + f"{url}", + json={"read": True, "write": True, "delete": True}, + ) + _, error = await assert_status(resp, status.HTTP_403_FORBIDDEN) + assert error + + # cannot REPLACE with everyone group + url = client.app.router["replace_tag_group"].url_for( + tag_id=f"{user_tag_id}", group_id=f"{EVERYONE_GROUP_ID}" + ) + resp = await client.put( + f"{url}", + json={"read": True, "write": True, "delete": True}, + ) + _, error = await assert_status(resp, status.HTTP_403_FORBIDDEN) + assert error + + # cannot DELETE with everyone group + url = client.app.router["delete_tag_group"].url_for( + tag_id=f"{user_tag_id}", group_id=f"{EVERYONE_GROUP_ID}" + ) + resp = await client.delete( + f"{url}", + json={"read": True, "write": True, "delete": True}, + ) + _, error = await assert_status(resp, status.HTTP_403_FORBIDDEN) + assert error + + +@pytest.fixture +def product_name() -> str: + return "osparc" + + +@pytest.mark.parametrize( + "user_role,expected_status", + [ + ( + role, + # granted only to: + status.HTTP_403_FORBIDDEN + if role < UserRole.TESTER + else status.HTTP_201_CREATED, + ) + for role in UserRole + if role >= UserRole.USER + ], +) +async def test_can_only_share_tag_with_product_group_if_granted_by_role( + client: TestClient, + logged_user: UserInfoDict, + user_role: UserRole, + user_tag_id: IdInt, + expected_status: int, + _clean_tags_table: None, + product_name: ProductName, +): + assert client.app + assert UserRole(logged_user["role"]) == user_role + + product_group_id = get_product(client.app, product_name=product_name).group_id + + # cannot SHARE with everyone group + url = client.app.router["create_tag_group"].url_for( + tag_id=f"{user_tag_id}", group_id=f"{product_group_id}" + ) + resp = await client.post( + f"{url}", + json={"read": True, "write": True, "delete": True}, + ) + await assert_status(resp, expected_status) diff --git a/services/web/server/tests/unit/with_dbs/04/wallets/test_wallets_groups.py b/services/web/server/tests/unit/with_dbs/04/wallets/test_wallets_groups.py index cf47474daaae..cd21bfea509d 100644 --- a/services/web/server/tests/unit/with_dbs/04/wallets/test_wallets_groups.py +++ b/services/web/server/tests/unit/with_dbs/04/wallets/test_wallets_groups.py @@ -24,6 +24,8 @@ async def test_wallets_groups_full_workflow( expected: HTTPStatus, wallets_clean_db: AsyncIterator[None], ): + assert client.app + # create a new wallet url = client.app.router["create_wallet"].url_for() resp = await client.post( @@ -39,9 +41,9 @@ async def test_wallets_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 async with NewUser( app=client.app, @@ -64,9 +66,9 @@ async def test_wallets_groups_full_workflow( data, _ = await assert_status(resp, status.HTTP_200_OK) assert len(data) == 2 assert data[1]["gid"] == new_user["primary_gid"] - assert data[1]["read"] == True - assert data[1]["write"] == False - assert data[1]["delete"] == False + assert data[1]["read"] is True + assert data[1]["write"] is False + assert data[1]["delete"] is False # Update the wallet permissions of the added user url = client.app.router["update_wallet_group"].url_for( @@ -78,9 +80,9 @@ async def test_wallets_groups_full_workflow( ) data, _ = await assert_status(resp, status.HTTP_200_OK) assert data["gid"] == new_user["primary_gid"] - assert data["read"] == True - assert data["write"] == True - assert data["delete"] == False + assert data["read"] is True + assert data["write"] is True + assert data["delete"] is False # List the wallet groups url = client.app.router["list_wallet_groups"].url_for( @@ -90,9 +92,9 @@ async def test_wallets_groups_full_workflow( data, _ = await assert_status(resp, status.HTTP_200_OK) assert len(data) == 2 assert data[1]["gid"] == new_user["primary_gid"] - assert data[1]["read"] == True - assert data[1]["write"] == True - assert data[1]["delete"] == False + assert data[1]["read"] is True + assert data[1]["write"] is True + assert data[1]["delete"] is False # Delete the wallet group url = client.app.router["delete_wallet_group"].url_for( diff --git a/services/web/server/tests/unit/with_dbs/04/workspaces/test_workspaces__list_projects_full_search.py b/services/web/server/tests/unit/with_dbs/04/workspaces/test_workspaces__list_projects_full_search.py index 6c146bb5a1ff..99bbaffc4a2e 100644 --- a/services/web/server/tests/unit/with_dbs/04/workspaces/test_workspaces__list_projects_full_search.py +++ b/services/web/server/tests/unit/with_dbs/04/workspaces/test_workspaces__list_projects_full_search.py @@ -165,12 +165,11 @@ async def test_workspaces__list_projects_full_search( assert sorted_data[2]["folderId"] == root_folder["folderId"] -@pytest.mark.parametrize("user_role,expected", [(UserRole.USER, status.HTTP_200_OK)]) +@pytest.mark.parametrize("user_role", [UserRole.USER]) async def test__list_projects_full_search_with_query_parameters( client: TestClient, logged_user: UserInfoDict, user_project: ProjectDict, - expected: HTTPStatus, mock_catalog_api_get_services_for_user_in_product: MockerFixture, fake_project: ProjectDict, workspaces_clean_db: None, @@ -220,14 +219,14 @@ async def test__list_projects_full_search_with_query_parameters( resp = await client.post( f"{url}", json={"name": "tag1", "description": "description1", "color": "#f00"} ) - added_tag, _ = await assert_status(resp, expected) + added_tag, _ = await assert_status(resp, status.HTTP_201_CREATED) # Add tag to study url = client.app.router["add_project_tag"].url_for( project_uuid=project["uuid"], tag_id=str(added_tag.get("id")) ) resp = await client.post(f"{url}") - data, _ = await assert_status(resp, expected) + data, _ = await assert_status(resp, status.HTTP_200_OK) # Full search with tag_ids base_url = client.app.router["list_projects_full_search"].url_for()