diff --git a/api/specs/web-server/_folders.py b/api/specs/web-server/_folders.py index db5ca1416f7..ee529da655c 100644 --- a/api/specs/web-server/_folders.py +++ b/api/specs/web-server/_folders.py @@ -10,21 +10,18 @@ from typing import Annotated from fastapi import APIRouter, Depends, Query, status -from models_library.api_schemas_webserver.folders import ( +from models_library.api_schemas_webserver.folders_v2 import ( CreateFolderBodyParams, FolderGet, PutFolderBodyParams, ) +from models_library.folders import FolderID from models_library.generics import Envelope from models_library.rest_pagination import PageQueryParameters +from models_library.workspaces import WorkspaceID from pydantic import Json from simcore_service_webserver._meta import API_VTAG from simcore_service_webserver.folders._folders_handlers import FoldersPathParams -from simcore_service_webserver.folders._groups_api import FolderGroupGet -from simcore_service_webserver.folders._groups_handlers import ( - _FoldersGroupsBodyParams, - _FoldersGroupsPathParams, -) router = APIRouter( prefix=f"/{API_VTAG}", @@ -51,6 +48,8 @@ async def create_folder(_body: CreateFolderBodyParams): ) async def list_folders( params: Annotated[PageQueryParameters, Depends()], + folder_id: FolderID | None = None, + workspace_id: WorkspaceID | None = None, order_by: Annotated[ Json, Query( @@ -86,45 +85,3 @@ async def replace_folder( ) async def delete_folder(_path: Annotated[FoldersPathParams, Depends()]): ... - - -### Folders groups - - -@router.post( - "/folders/{folder_id}/groups/{group_id}", - response_model=Envelope[FolderGroupGet], - status_code=status.HTTP_201_CREATED, -) -async def create_folder_group( - _path: Annotated[_FoldersGroupsPathParams, Depends()], - _body: _FoldersGroupsBodyParams, -): - ... - - -@router.get( - "/folders/{folder_id}/groups", - response_model=Envelope[list[FolderGroupGet]], -) -async def list_folder_groups(_path: Annotated[FoldersPathParams, Depends()]): - ... - - -@router.put( - "/folders/{folder_id}/groups/{group_id}", - response_model=Envelope[FolderGroupGet], -) -async def replace_folder_group( - _path: Annotated[_FoldersGroupsPathParams, Depends()], - _body: _FoldersGroupsBodyParams, -): - ... - - -@router.delete( - "/folders/{folder_id}/groups/{group_id}", - status_code=status.HTTP_204_NO_CONTENT, -) -async def delete_folder_group(_path: Annotated[_FoldersGroupsPathParams, Depends()]): - ... diff --git a/api/specs/web-server/_workspaces.py b/api/specs/web-server/_workspaces.py new file mode 100644 index 00000000000..1341fce5025 --- /dev/null +++ b/api/specs/web-server/_workspaces.py @@ -0,0 +1,113 @@ +""" Helper script to generate OAS automatically +""" + +# pylint: disable=redefined-outer-name +# pylint: disable=unused-argument +# pylint: disable=unused-variable +# pylint: disable=too-many-arguments + + +from fastapi import APIRouter, status +from models_library.api_schemas_webserver.workspaces import ( + CreateWorkspaceBodyParams, + PutWorkspaceBodyParams, + WorkspaceGet, +) +from models_library.generics import Envelope +from models_library.users import GroupID +from models_library.workspaces import WorkspaceID +from simcore_service_webserver._meta import API_VTAG +from simcore_service_webserver.workspaces._groups_api import WorkspaceGroupGet +from simcore_service_webserver.workspaces._groups_handlers import ( + _WorkspacesGroupsBodyParams, +) + +router = APIRouter( + prefix=f"/{API_VTAG}", + tags=[ + "workspaces", + ], +) + +### Workspaces + + +@router.post( + "/workspaces", + response_model=Envelope[WorkspaceGet], + status_code=status.HTTP_201_CREATED, +) +async def create_workspace(body: CreateWorkspaceBodyParams): + ... + + +@router.get( + "/workspaces", + response_model=Envelope[list[WorkspaceGet]], +) +async def list_workspaces(): + ... + + +@router.get( + "/workspaces/{workspace_id}", + response_model=Envelope[WorkspaceGet], +) +async def get_workspace(workspace_id: WorkspaceID): + ... + + +@router.put( + "/workspaces/{workspace_id}", + response_model=Envelope[WorkspaceGet], +) +async def replace_workspace(workspace_id: WorkspaceID, body: PutWorkspaceBodyParams): + ... + + +@router.delete( + "/workspaces/{workspace_id}", + status_code=status.HTTP_204_NO_CONTENT, +) +async def delete_workspace(workspace_id: WorkspaceID): + ... + + +### Workspaces groups + + +@router.post( + "/workspaces/{workspace_id}/groups/{group_id}", + response_model=Envelope[WorkspaceGroupGet], + status_code=status.HTTP_201_CREATED, +) +async def create_workspace_group( + workspace_id: WorkspaceID, group_id: GroupID, body: _WorkspacesGroupsBodyParams +): + ... + + +@router.get( + "/workspaces/{workspace_id}/groups", + response_model=Envelope[list[WorkspaceGroupGet]], +) +async def list_workspace_groups(workspace_id: WorkspaceID): + ... + + +@router.put( + "/workspaces/{workspace_id}/groups/{group_id}", + response_model=Envelope[WorkspaceGroupGet], +) +async def replace_workspace_group( + workspace_id: WorkspaceID, group_id: GroupID, body: _WorkspacesGroupsBodyParams +): + ... + + +@router.delete( + "/workspaces/{workspace_id}/groups/{group_id}", + status_code=status.HTTP_204_NO_CONTENT, +) +async def delete_workspace_group(workspace_id: WorkspaceID, group_id: GroupID): + ... diff --git a/api/specs/web-server/openapi.py b/api/specs/web-server/openapi.py index c8801f15619..ce7413949ab 100644 --- a/api/specs/web-server/openapi.py +++ b/api/specs/web-server/openapi.py @@ -50,6 +50,7 @@ "_users", "_version_control", "_wallets", + "_workspaces", ) ] diff --git a/packages/models-library/src/models_library/access_rights.py b/packages/models-library/src/models_library/access_rights.py new file mode 100644 index 00000000000..b1218b858a1 --- /dev/null +++ b/packages/models-library/src/models_library/access_rights.py @@ -0,0 +1,10 @@ +from pydantic import BaseModel, Extra, Field + + +class AccessRights(BaseModel): + read: bool = Field(..., description="has read access") + write: bool = Field(..., description="has write access") + delete: bool = Field(..., description="has deletion rights") + + class Config: + extra = Extra.forbid diff --git a/packages/models-library/src/models_library/api_schemas_webserver/folders_v2.py b/packages/models-library/src/models_library/api_schemas_webserver/folders_v2.py new file mode 100644 index 00000000000..a3e52b0d7b9 --- /dev/null +++ b/packages/models-library/src/models_library/api_schemas_webserver/folders_v2.py @@ -0,0 +1,54 @@ +from datetime import datetime +from typing import NamedTuple + +from models_library.basic_types import IDStr +from models_library.folders import FolderID +from models_library.users import GroupID +from models_library.utils.common_validators import null_or_none_str_to_none_validator +from models_library.workspaces import WorkspaceID +from pydantic import Extra, PositiveInt, validator + +from ._base import InputSchema, OutputSchema + + +class FolderGet(OutputSchema): + folder_id: FolderID + parent_folder_id: FolderID | None = None + name: str + created_at: datetime + modified_at: datetime + owner: GroupID + + +class FolderGetPage(NamedTuple): + items: list[FolderGet] + total: PositiveInt + + +class CreateFolderBodyParams(InputSchema): + name: IDStr + parent_folder_id: FolderID | None = None + workspace_id: WorkspaceID | None = None + + class Config: + extra = Extra.forbid + + _null_or_none_str_to_none_validator = validator( + "parent_folder_id", allow_reuse=True, pre=True + )(null_or_none_str_to_none_validator) + + _null_or_none_str_to_none_validator2 = validator( + "workspace_id", allow_reuse=True, pre=True + )(null_or_none_str_to_none_validator) + + +class PutFolderBodyParams(InputSchema): + name: IDStr + parent_folder_id: FolderID | None + + class Config: + extra = Extra.forbid + + _null_or_none_str_to_none_validator = validator( + "parent_folder_id", allow_reuse=True, pre=True + )(null_or_none_str_to_none_validator) diff --git a/packages/models-library/src/models_library/api_schemas_webserver/projects.py b/packages/models-library/src/models_library/api_schemas_webserver/projects.py index 651445ee433..fdfe4fb0666 100644 --- a/packages/models-library/src/models_library/api_schemas_webserver/projects.py +++ b/packages/models-library/src/models_library/api_schemas_webserver/projects.py @@ -7,6 +7,7 @@ from typing import Any, Literal, TypeAlias +from models_library.workspaces import WorkspaceID from pydantic import Field, validator from ..api_schemas_long_running_tasks.tasks import TaskGet @@ -39,9 +40,10 @@ class ProjectCreateNew(InputSchema): tags: list[int] = Field(default_factory=list) classifiers: list[ClassifierID] = Field(default_factory=list) ui: StudyUI | None = None + workspace_id: WorkspaceID | None = None _empty_is_none = validator( - "uuid", "thumbnail", "description", allow_reuse=True, pre=True + "uuid", "thumbnail", "description", "workspace_id", allow_reuse=True, pre=True )(empty_str_to_none_pre_validator) @@ -74,6 +76,7 @@ class ProjectGet(OutputSchema): quality: dict[str, Any] = {} dev: dict | None permalink: ProjectPermalink = FieldNotRequired() + workspace_id: WorkspaceID | None _empty_description = validator("description", allow_reuse=True, pre=True)( none_to_empty_str_pre_validator diff --git a/packages/models-library/src/models_library/api_schemas_webserver/workspaces.py b/packages/models-library/src/models_library/api_schemas_webserver/workspaces.py new file mode 100644 index 00000000000..0ba98ab4ec3 --- /dev/null +++ b/packages/models-library/src/models_library/api_schemas_webserver/workspaces.py @@ -0,0 +1,44 @@ +from datetime import datetime +from typing import NamedTuple + +from models_library.basic_types import IDStr +from models_library.users import GroupID +from models_library.workspaces import WorkspaceID +from pydantic import Extra, PositiveInt + +from ..access_rights import AccessRights +from ._base import InputSchema, OutputSchema + + +class WorkspaceGet(OutputSchema): + workspace_id: WorkspaceID + name: str + description: str | None + thumbnail: str | None + created_at: datetime + modified_at: datetime + my_access_rights: AccessRights + access_rights: dict[GroupID, AccessRights] + + +class WorkspaceGetPage(NamedTuple): + items: list[WorkspaceGet] + total: PositiveInt + + +class CreateWorkspaceBodyParams(InputSchema): + name: str + description: str | None = None + thumbnail: str | None = None + + class Config: + extra = Extra.forbid + + +class PutWorkspaceBodyParams(InputSchema): + name: IDStr + description: str | None = None + thumbnail: str | None = None + + class Config: + extra = Extra.forbid diff --git a/packages/models-library/src/models_library/folders.py b/packages/models-library/src/models_library/folders.py index f8d8377d8c3..73262e1e647 100644 --- a/packages/models-library/src/models_library/folders.py +++ b/packages/models-library/src/models_library/folders.py @@ -1,5 +1,36 @@ +from datetime import datetime from typing import TypeAlias -from pydantic import PositiveInt +from models_library.users import GroupID, UserID +from models_library.workspaces import WorkspaceID +from pydantic import BaseModel, Field, PositiveInt FolderID: TypeAlias = PositiveInt + + +# +# DB +# + + +class FolderDB(BaseModel): + folder_id: FolderID + name: str + parent_folder_id: FolderID | None + created_by_gid: GroupID = Field( + ..., + description="GID of the group that owns this wallet", + ) + created: datetime = Field( + ..., + description="Timestamp on creation", + ) + modified: datetime = Field( + ..., + description="Timestamp of last modification", + ) + user_id: UserID | None + workspace_id: WorkspaceID | None + + class Config: + orm_mode = True diff --git a/packages/models-library/src/models_library/projects.py b/packages/models-library/src/models_library/projects.py index 5a4063b2b7d..6c2036caa5a 100644 --- a/packages/models-library/src/models_library/projects.py +++ b/packages/models-library/src/models_library/projects.py @@ -8,6 +8,7 @@ from typing import Any, Final, TypeAlias from uuid import UUID +from models_library.workspaces import WorkspaceID from pydantic import BaseModel, ConstrainedStr, Extra, Field, validator from .basic_regex import DATE_RE, UUID_RE_BASE @@ -173,6 +174,12 @@ class Project(BaseProjectModel): default=None, description="object used for development purposes only" ) + workspace_id: WorkspaceID | None = Field( + default=None, + description="To which workspace project belongs. If None, belongs to private user workspace.", + alias="workspaceId", + ) + class Config: description = "Document that stores metadata, pipeline and UI setup of a study" title = "osparc-simcore project" diff --git a/packages/models-library/src/models_library/workspaces.py b/packages/models-library/src/models_library/workspaces.py new file mode 100644 index 00000000000..c08e02501cb --- /dev/null +++ b/packages/models-library/src/models_library/workspaces.py @@ -0,0 +1,43 @@ +from datetime import datetime +from typing import TypeAlias + +from models_library.access_rights import AccessRights +from models_library.users import GroupID +from pydantic import BaseModel, Field, PositiveInt + +WorkspaceID: TypeAlias = PositiveInt + + +# +# DB +# + + +class WorkspaceDB(BaseModel): + workspace_id: WorkspaceID + name: str + description: str | None + owner_primary_gid: PositiveInt = Field( + ..., + description="GID of the group that owns this wallet", + ) + thumbnail: str | None + created: datetime = Field( + ..., + description="Timestamp on creation", + ) + modified: datetime = Field( + ..., + description="Timestamp of last modification", + ) + + class Config: + orm_mode = True + + +class UserWorkspaceAccessRightsDB(WorkspaceDB): + my_access_rights: AccessRights + access_rights: dict[GroupID, AccessRights] + + class Config: + orm_mode = True diff --git a/packages/postgres-database/src/simcore_postgres_database/migration/versions/9f381dcb9b95_add_workspaces_and_folders_v2.py b/packages/postgres-database/src/simcore_postgres_database/migration/versions/9f381dcb9b95_add_workspaces_and_folders_v2.py new file mode 100644 index 00000000000..7302c3e22b1 --- /dev/null +++ b/packages/postgres-database/src/simcore_postgres_database/migration/versions/9f381dcb9b95_add_workspaces_and_folders_v2.py @@ -0,0 +1,238 @@ +"""add_workspaces_and_folders_v2 + +Revision ID: 9f381dcb9b95 +Revises: 926c3eb2254e +Create Date: 2024-09-03 05:49:16.581965+00:00 + +""" +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision = "9f381dcb9b95" +down_revision = "926c3eb2254e" +branch_labels = None +depends_on = None + + +# ------------------------ TRIGGERS +new_workspace_trigger = sa.DDL( + """ +DROP TRIGGER IF EXISTS workspace_modification on workspaces; +CREATE TRIGGER workspace_modification +AFTER INSERT ON workspaces + FOR EACH ROW + EXECUTE PROCEDURE set_workspace_to_owner_group(); +""" +) + + +# --------------------------- PROCEDURES +assign_workspace_access_rights_to_owner_group_procedure = sa.DDL( + """ +CREATE OR REPLACE FUNCTION set_workspace_to_owner_group() RETURNS TRIGGER AS $$ +DECLARE + group_id BIGINT; +BEGIN + IF TG_OP = 'INSERT' THEN + INSERT INTO "workspaces_access_rights" ("gid", "workspace_id", "read", "write", "delete") VALUES (NEW.owner_primary_gid, NEW.workspace_id, TRUE, TRUE, TRUE); + END IF; + RETURN NULL; +END; $$ LANGUAGE 'plpgsql'; + """ +) + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_table( + "workspaces", + sa.Column("workspace_id", sa.BigInteger(), autoincrement=True, nullable=False), + sa.Column("name", sa.String(), nullable=False), + sa.Column("description", sa.String(), nullable=True), + sa.Column("thumbnail", sa.String(), nullable=True), + sa.Column("owner_primary_gid", sa.BigInteger(), nullable=False), + sa.Column("product_name", sa.String(), nullable=False), + sa.Column( + "created", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + nullable=False, + ), + sa.Column( + "modified", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + nullable=False, + ), + sa.ForeignKeyConstraint( + ["owner_primary_gid"], + ["groups.gid"], + name="fk_workspaces_gid_groups", + onupdate="CASCADE", + ondelete="RESTRICT", + ), + sa.PrimaryKeyConstraint("workspace_id"), + ) + op.create_table( + "folders_v2", + sa.Column("folder_id", sa.BigInteger(), autoincrement=True, nullable=False), + sa.Column("name", sa.String(), nullable=False), + sa.Column("parent_folder_id", sa.BigInteger(), nullable=True), + sa.Column("product_name", sa.String(), nullable=False), + sa.Column("user_id", sa.BigInteger(), nullable=True), + sa.Column("workspace_id", sa.BigInteger(), nullable=True), + sa.Column("created_by_gid", sa.BigInteger(), nullable=True), + sa.Column( + "created", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + nullable=False, + ), + sa.Column( + "modified", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + nullable=False, + ), + sa.ForeignKeyConstraint( + ["created_by_gid"], + ["groups.gid"], + name="fk_new_folders_to_groups_gid", + ondelete="SET NULL", + ), + sa.ForeignKeyConstraint( + ["parent_folder_id"], + ["folders_v2.folder_id"], + name="fk_new_folders_to_folders_id", + ), + sa.ForeignKeyConstraint( + ["product_name"], + ["products.name"], + name="fk_new_folders_to_products_name", + onupdate="CASCADE", + ondelete="CASCADE", + ), + sa.ForeignKeyConstraint( + ["user_id"], + ["users.id"], + name="fk_folders_to_user_id", + onupdate="CASCADE", + ondelete="CASCADE", + ), + sa.ForeignKeyConstraint( + ["workspace_id"], + ["workspaces.workspace_id"], + name="fk_folders_to_workspace_id", + onupdate="CASCADE", + ondelete="CASCADE", + ), + sa.PrimaryKeyConstraint("folder_id"), + ) + op.create_table( + "workspaces_access_rights", + sa.Column("workspace_id", sa.BigInteger(), nullable=True), + sa.Column("gid", sa.BigInteger(), nullable=True), + sa.Column( + "read", sa.Boolean(), server_default=sa.text("false"), nullable=False + ), + sa.Column( + "write", sa.Boolean(), server_default=sa.text("false"), nullable=False + ), + sa.Column( + "delete", sa.Boolean(), server_default=sa.text("false"), nullable=False + ), + sa.Column( + "created", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + nullable=False, + ), + sa.Column( + "modified", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + nullable=False, + ), + sa.ForeignKeyConstraint( + ["gid"], + ["groups.gid"], + name="fk_workspaces_access_rights_gid_groups", + onupdate="CASCADE", + ondelete="CASCADE", + ), + sa.ForeignKeyConstraint( + ["workspace_id"], + ["workspaces.workspace_id"], + name="fk_workspaces_access_rights_id_workspaces", + onupdate="CASCADE", + ondelete="CASCADE", + ), + sa.UniqueConstraint("workspace_id", "gid"), + ) + op.create_table( + "projects_to_folders", + sa.Column("project_uuid", sa.String(), nullable=True), + sa.Column("folder_id", sa.BigInteger(), nullable=True), + sa.Column("user_id", sa.BigInteger(), nullable=True), + sa.Column( + "created", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + nullable=False, + ), + sa.Column( + "modified", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + nullable=False, + ), + sa.ForeignKeyConstraint( + ["folder_id"], + ["folders_v2.folder_id"], + name="fk_projects_to_folders_to_folders_id", + onupdate="CASCADE", + ondelete="CASCADE", + ), + sa.ForeignKeyConstraint( + ["project_uuid"], + ["projects.uuid"], + name="fk_projects_to_folders_to_projects_uuid", + onupdate="CASCADE", + ondelete="CASCADE", + ), + sa.ForeignKeyConstraint( + ["user_id"], + ["users.id"], + name="fk_projects_to_folders_to_user_id", + onupdate="CASCADE", + ondelete="CASCADE", + ), + sa.UniqueConstraint("project_uuid", "folder_id", "user_id"), + ) + op.add_column("projects", sa.Column("workspace_id", sa.BigInteger(), nullable=True)) + op.create_foreign_key( + "fk_projects_to_workspaces_id", + "projects", + "workspaces", + ["workspace_id"], + ["workspace_id"], + onupdate="CASCADE", + ondelete="CASCADE", + ) + # ### end Alembic commands ### + op.execute(assign_workspace_access_rights_to_owner_group_procedure) + op.execute(new_workspace_trigger) + + +def downgrade(): + op.execute(new_workspace_trigger) + op.execute(assign_workspace_access_rights_to_owner_group_procedure) + # ### commands auto generated by Alembic - please adjust! ### + op.drop_constraint("fk_projects_to_workspaces_id", "projects", type_="foreignkey") + op.drop_column("projects", "workspace_id") + op.drop_table("projects_to_folders") + op.drop_table("workspaces_access_rights") + op.drop_table("folders_v2") + op.drop_table("workspaces") + # ### end Alembic commands ### diff --git a/packages/postgres-database/src/simcore_postgres_database/models/folders_v2.py b/packages/postgres-database/src/simcore_postgres_database/models/folders_v2.py new file mode 100644 index 00000000000..b1393bf5367 --- /dev/null +++ b/packages/postgres-database/src/simcore_postgres_database/models/folders_v2.py @@ -0,0 +1,77 @@ +import sqlalchemy as sa + +from ._common import column_created_datetime, column_modified_datetime +from .base import metadata +from .workspaces import workspaces + +folders_v2 = sa.Table( + "folders_v2", + metadata, + sa.Column( + "folder_id", + sa.BigInteger, + nullable=False, + autoincrement=True, + primary_key=True, + ), + sa.Column( + "name", + sa.String, + nullable=False, + doc="name of the folder", + ), + sa.Column( + "parent_folder_id", + sa.BigInteger, + sa.ForeignKey( + "folders_v2.folder_id", + name="fk_new_folders_to_folders_id", + ), + nullable=True, + ), + sa.Column( + "product_name", + sa.String, + sa.ForeignKey( + "products.name", + onupdate="CASCADE", + ondelete="CASCADE", + name="fk_new_folders_to_products_name", + ), + nullable=False, + ), + sa.Column( + "user_id", + sa.BigInteger, + sa.ForeignKey( + "users.id", + onupdate="CASCADE", + ondelete="CASCADE", + name="fk_folders_to_user_id", + ), + nullable=True, + ), + sa.Column( + "workspace_id", + sa.BigInteger, + sa.ForeignKey( + workspaces.c.workspace_id, + onupdate="CASCADE", + ondelete="CASCADE", + name="fk_folders_to_workspace_id", + ), + nullable=True, + ), + sa.Column( + "created_by_gid", + sa.BigInteger, + sa.ForeignKey( + "groups.gid", + name="fk_new_folders_to_groups_gid", + ondelete="SET NULL", + ), + nullable=True, + ), + column_created_datetime(timezone=True), + column_modified_datetime(timezone=True), +) diff --git a/packages/postgres-database/src/simcore_postgres_database/models/projects.py b/packages/postgres-database/src/simcore_postgres_database/models/projects.py index 36e9f11e33c..ae77ea5c5d0 100644 --- a/packages/postgres-database/src/simcore_postgres_database/models/projects.py +++ b/packages/postgres-database/src/simcore_postgres_database/models/projects.py @@ -141,6 +141,18 @@ class ProjectType(enum.Enum): default=False, doc="If true, the project is by default not listed in the API", ), + sa.Column( + "workspace_id", + sa.BigInteger, + sa.ForeignKey( + "workspaces.workspace_id", + onupdate="CASCADE", + ondelete="CASCADE", + name="fk_projects_to_workspaces_id", + ), + nullable=True, + default=None, + ), ) diff --git a/packages/postgres-database/src/simcore_postgres_database/models/projects_to_folders.py b/packages/postgres-database/src/simcore_postgres_database/models/projects_to_folders.py new file mode 100644 index 00000000000..ba2b7334621 --- /dev/null +++ b/packages/postgres-database/src/simcore_postgres_database/models/projects_to_folders.py @@ -0,0 +1,45 @@ +import sqlalchemy as sa + +from ._common import column_created_datetime, column_modified_datetime +from .base import metadata +from .folders_v2 import folders_v2 + +projects_to_folders = sa.Table( + "projects_to_folders", + metadata, + sa.Column( + "project_uuid", + sa.String, + sa.ForeignKey( + "projects.uuid", + name="fk_projects_to_folders_to_projects_uuid", + onupdate="CASCADE", + ondelete="CASCADE", + ), + ), + sa.Column( + "folder_id", + sa.BigInteger, + sa.ForeignKey( + folders_v2.c.folder_id, + name="fk_projects_to_folders_to_folders_id", + onupdate="CASCADE", + ondelete="CASCADE", + ), + ), + sa.Column( + "user_id", + sa.BigInteger, + sa.ForeignKey( + "users.id", + onupdate="CASCADE", + ondelete="CASCADE", + name="fk_projects_to_folders_to_user_id", + ), + nullable=True, + doc="If private workspace then user id is filled, otherwise its null", + ), + column_created_datetime(timezone=True), + column_modified_datetime(timezone=True), + sa.UniqueConstraint("project_uuid", "folder_id", "user_id"), +) diff --git a/packages/postgres-database/src/simcore_postgres_database/models/workspaces.py b/packages/postgres-database/src/simcore_postgres_database/models/workspaces.py new file mode 100644 index 00000000000..f4b76812a6c --- /dev/null +++ b/packages/postgres-database/src/simcore_postgres_database/models/workspaces.py @@ -0,0 +1,76 @@ +import sqlalchemy as sa + +from ._common import column_created_datetime, column_modified_datetime +from .base import metadata + +workspaces = sa.Table( + "workspaces", + metadata, + sa.Column( + "workspace_id", + sa.BigInteger, + nullable=False, + autoincrement=True, + primary_key=True, + doc="Workspace index", + ), + sa.Column("name", sa.String, nullable=False, doc="Display name"), + sa.Column("description", sa.String, nullable=True, doc="Short description"), + sa.Column( + "thumbnail", + sa.String, + nullable=True, + doc="Link to image as to workspace thumbnail", + ), + sa.Column( + "owner_primary_gid", + sa.BigInteger, + sa.ForeignKey( + "groups.gid", + name="fk_workspaces_gid_groups", + onupdate="CASCADE", + ondelete="RESTRICT", + ), + nullable=False, + doc="Identifier of the group that owns this workspace (Should be just PRIMARY GROUP)", + ), + sa.Column("product_name", sa.String, nullable=False, doc="Product name"), + column_created_datetime(timezone=True), + column_modified_datetime(timezone=True), +) + +# ------------------------ TRIGGERS +new_workspace_trigger = sa.DDL( + """ +DROP TRIGGER IF EXISTS workspace_modification on workspaces; +CREATE TRIGGER workspace_modification +AFTER INSERT ON workspaces + FOR EACH ROW + EXECUTE PROCEDURE set_workspace_to_owner_group(); +""" +) + + +# --------------------------- PROCEDURES +assign_workspace_access_rights_to_owner_group_procedure = sa.DDL( + """ +CREATE OR REPLACE FUNCTION set_workspace_to_owner_group() RETURNS TRIGGER AS $$ +DECLARE + group_id BIGINT; +BEGIN + IF TG_OP = 'INSERT' THEN + INSERT INTO "workspaces_access_rights" ("gid", "workspace_id", "read", "write", "delete") VALUES (NEW.owner_primary_gid, NEW.workspace_id, TRUE, TRUE, TRUE); + END IF; + RETURN NULL; +END; $$ LANGUAGE 'plpgsql'; + """ +) + +sa.event.listen( + workspaces, "after_create", assign_workspace_access_rights_to_owner_group_procedure +) +sa.event.listen( + workspaces, + "after_create", + new_workspace_trigger, +) diff --git a/packages/postgres-database/src/simcore_postgres_database/models/workspaces_access_rights.py b/packages/postgres-database/src/simcore_postgres_database/models/workspaces_access_rights.py new file mode 100644 index 00000000000..960ef643538 --- /dev/null +++ b/packages/postgres-database/src/simcore_postgres_database/models/workspaces_access_rights.py @@ -0,0 +1,60 @@ +import sqlalchemy as sa +from sqlalchemy.sql import expression + +from ._common import column_created_datetime, column_modified_datetime +from .base import metadata +from .groups import groups +from .workspaces import workspaces + +workspaces_access_rights = sa.Table( + "workspaces_access_rights", + metadata, + sa.Column( + "workspace_id", + sa.BigInteger, + sa.ForeignKey( + workspaces.c.workspace_id, + name="fk_workspaces_access_rights_id_workspaces", + onupdate="CASCADE", + ondelete="CASCADE", + ), + doc="Workspace unique ID", + ), + sa.Column( + "gid", + sa.BigInteger, + sa.ForeignKey( + groups.c.gid, + name="fk_workspaces_access_rights_gid_groups", + onupdate="CASCADE", + ondelete="CASCADE", + ), + doc="Group unique IDentifier", + ), + # Access Rights flags --- + sa.Column( + "read", + sa.Boolean, + nullable=False, + server_default=expression.false(), + doc="If true, group can use the workspace", + ), + sa.Column( + "write", + sa.Boolean, + nullable=False, + server_default=expression.false(), + doc="If true, group can modify the workspace", + ), + sa.Column( + "delete", + sa.Boolean, + nullable=False, + server_default=expression.false(), + doc="If true, group can delete the workspace", + ), + # ----- + column_created_datetime(timezone=True), + column_modified_datetime(timezone=True), + sa.UniqueConstraint("workspace_id", "gid"), +) diff --git a/packages/pytest-simcore/src/pytest_simcore/helpers/webserver_workspaces.py b/packages/pytest-simcore/src/pytest_simcore/helpers/webserver_workspaces.py new file mode 100644 index 00000000000..3d1f33ab029 --- /dev/null +++ b/packages/pytest-simcore/src/pytest_simcore/helpers/webserver_workspaces.py @@ -0,0 +1,43 @@ +import sqlalchemy as sa +from aiohttp import web +from models_library.users import GroupID +from models_library.workspaces import WorkspaceID +from simcore_postgres_database.models.workspaces_access_rights import ( + workspaces_access_rights, +) +from simcore_service_webserver.db.plugin import get_database_engine +from sqlalchemy.dialects.postgresql import insert as pg_insert + + +async def update_or_insert_workspace_group( + app: web.Application, + workspace_id: WorkspaceID, + group_id: GroupID, + *, + read: bool, + write: bool, + delete: bool, +) -> None: + async with get_database_engine(app).acquire() as conn: + insert_stmt = pg_insert(workspaces_access_rights).values( + workspace_id=workspace_id, + gid=group_id, + read=read, + write=write, + delete=delete, + created=sa.func.now(), + modified=sa.func.now(), + ) + on_update_stmt = insert_stmt.on_conflict_do_update( + index_elements=[ + workspaces_access_rights.c.workspace_id, + workspaces_access_rights.c.gid, + ], + set_={ + "read": insert_stmt.excluded.read, + "write": insert_stmt.excluded.write, + "delete": insert_stmt.excluded.delete, + "modified": sa.func.now(), + }, + ) + await conn.execute(on_update_stmt) diff --git a/services/director/src/simcore_service_director/api/v0/schemas/project-v0.0.1.json b/services/director/src/simcore_service_director/api/v0/schemas/project-v0.0.1.json index 8c178845ccb..97016fe45e3 100644 --- a/services/director/src/simcore_service_director/api/v0/schemas/project-v0.0.1.json +++ b/services/director/src/simcore_service_director/api/v0/schemas/project-v0.0.1.json @@ -763,6 +763,9 @@ "type": "object", "title": "Quality", "description": "Object containing Quality Assessment related data" + }, + "workspaceId": { + "type": ["integer", "null"] } } } diff --git a/services/storage/src/simcore_service_storage/db_access_layer.py b/services/storage/src/simcore_service_storage/db_access_layer.py index aadaf9a87b9..ceae121ccd0 100644 --- a/services/storage/src/simcore_service_storage/db_access_layer.py +++ b/services/storage/src/simcore_service_storage/db_access_layer.py @@ -135,7 +135,9 @@ def assemble_array_groups(user_group_ids: list[GroupID]) -> str: "delete", project_to_groups.c.delete, ), - ).label("access_rights"), + ) + .filter(project_to_groups.c.read) # Filters out entries where "read" is False + .label("access_rights"), ).group_by(project_to_groups.c.project_uuid) ).subquery("access_rights_subquery") 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 8374a7e3f39..63abbd2dbd5 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 @@ -1222,6 +1222,22 @@ paths: summary: List Folders operationId: list_folders parameters: + - required: false + schema: + title: Folder Id + exclusiveMinimum: true + type: integer + minimum: 0 + name: folder_id + in: query + - required: false + schema: + title: Workspace Id + exclusiveMinimum: true + type: integer + minimum: 0 + name: workspace_id + in: query - description: Order by field (modified_at|name|description) and direction (asc|desc). The default sorting order is ascending. required: false @@ -1257,7 +1273,7 @@ paths: content: application/json: schema: - $ref: '#/components/schemas/Envelope_list_models_library.api_schemas_webserver.folders.FolderGet__' + $ref: '#/components/schemas/Envelope_list_models_library.api_schemas_webserver.folders_v2.FolderGet__' post: tags: - folders @@ -1342,124 +1358,6 @@ paths: responses: '204': description: Successful Response - /v0/folders/{folder_id}/groups/{group_id}: - put: - tags: - - folders - summary: Replace Folder Group - operationId: replace_folder_group - parameters: - - required: true - schema: - title: Folder Id - exclusiveMinimum: true - type: integer - minimum: 0 - name: folder_id - in: path - - required: true - schema: - title: Group Id - exclusiveMinimum: true - type: integer - minimum: 0 - name: group_id - in: path - requestBody: - content: - application/json: - schema: - $ref: '#/components/schemas/_FoldersGroupsBodyParams' - required: true - responses: - '200': - description: Successful Response - content: - application/json: - schema: - $ref: '#/components/schemas/Envelope_FolderGroupGet_' - post: - tags: - - folders - summary: Create Folder Group - operationId: create_folder_group - parameters: - - required: true - schema: - title: Folder Id - exclusiveMinimum: true - type: integer - minimum: 0 - name: folder_id - in: path - - required: true - schema: - title: Group Id - exclusiveMinimum: true - type: integer - minimum: 0 - name: group_id - in: path - requestBody: - content: - application/json: - schema: - $ref: '#/components/schemas/_FoldersGroupsBodyParams' - required: true - responses: - '201': - description: Successful Response - content: - application/json: - schema: - $ref: '#/components/schemas/Envelope_FolderGroupGet_' - delete: - tags: - - folders - summary: Delete Folder Group - operationId: delete_folder_group - parameters: - - required: true - schema: - title: Folder Id - exclusiveMinimum: true - type: integer - minimum: 0 - name: folder_id - in: path - - required: true - schema: - title: Group Id - exclusiveMinimum: true - type: integer - minimum: 0 - name: group_id - in: path - responses: - '204': - description: Successful Response - /v0/folders/{folder_id}/groups: - get: - tags: - - folders - summary: List Folder Groups - operationId: list_folder_groups - parameters: - - required: true - schema: - title: Folder Id - exclusiveMinimum: true - type: integer - minimum: 0 - name: folder_id - in: path - responses: - '200': - description: Successful Response - content: - application/json: - schema: - $ref: '#/components/schemas/Envelope_list_simcore_service_webserver.folders._groups_api.FolderGroupGet__' /v0/groups: get: tags: @@ -2466,6 +2364,14 @@ paths: minimum: 0 name: folder_id in: query + - required: false + schema: + title: Workspace Id + exclusiveMinimum: true + type: integer + minimum: 0 + name: workspace_id + in: query responses: '200': description: Successful Response @@ -5466,6 +5372,221 @@ paths: application/json: schema: $ref: '#/components/schemas/Envelope_list_simcore_service_webserver.wallets._groups_api.WalletGroupGet__' + /v0/workspaces: + get: + tags: + - workspaces + summary: List Workspaces + operationId: list_workspaces + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/Envelope_list_models_library.api_schemas_webserver.workspaces.WorkspaceGet__' + post: + tags: + - workspaces + summary: Create Workspace + operationId: create_workspace + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/CreateWorkspaceBodyParams' + required: true + responses: + '201': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/Envelope_WorkspaceGet_' + /v0/workspaces/{workspace_id}: + get: + tags: + - workspaces + summary: Get Workspace + operationId: get_workspace + parameters: + - required: true + schema: + title: Workspace Id + exclusiveMinimum: true + type: integer + minimum: 0 + name: workspace_id + in: path + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/Envelope_WorkspaceGet_' + put: + tags: + - workspaces + summary: Replace Workspace + operationId: replace_workspace + parameters: + - required: true + schema: + title: Workspace Id + exclusiveMinimum: true + type: integer + minimum: 0 + name: workspace_id + in: path + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/PutWorkspaceBodyParams' + required: true + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/Envelope_WorkspaceGet_' + delete: + tags: + - workspaces + summary: Delete Workspace + operationId: delete_workspace + parameters: + - required: true + schema: + title: Workspace Id + exclusiveMinimum: true + type: integer + minimum: 0 + name: workspace_id + in: path + responses: + '204': + description: Successful Response + /v0/workspaces/{workspace_id}/groups/{group_id}: + put: + tags: + - workspaces + summary: Replace Workspace Group + operationId: replace_workspace_group + parameters: + - required: true + schema: + title: Workspace Id + exclusiveMinimum: true + type: integer + minimum: 0 + name: workspace_id + in: path + - required: true + schema: + title: Group Id + exclusiveMinimum: true + type: integer + minimum: 0 + name: group_id + in: path + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/_WorkspacesGroupsBodyParams' + required: true + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/Envelope_WorkspaceGroupGet_' + post: + tags: + - workspaces + summary: Create Workspace Group + operationId: create_workspace_group + parameters: + - required: true + schema: + title: Workspace Id + exclusiveMinimum: true + type: integer + minimum: 0 + name: workspace_id + in: path + - required: true + schema: + title: Group Id + exclusiveMinimum: true + type: integer + minimum: 0 + name: group_id + in: path + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/_WorkspacesGroupsBodyParams' + required: true + responses: + '201': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/Envelope_WorkspaceGroupGet_' + delete: + tags: + - workspaces + summary: Delete Workspace Group + operationId: delete_workspace_group + parameters: + - required: true + schema: + title: Workspace Id + exclusiveMinimum: true + type: integer + minimum: 0 + name: workspace_id + in: path + - required: true + schema: + title: Group Id + exclusiveMinimum: true + type: integer + minimum: 0 + name: group_id + in: path + responses: + '204': + description: Successful Response + /v0/workspaces/{workspace_id}/groups: + get: + tags: + - workspaces + summary: List Workspace Groups + operationId: list_workspace_groups + parameters: + - required: true + schema: + title: Workspace Id + exclusiveMinimum: true + type: integer + minimum: 0 + name: workspace_id + in: path + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/Envelope_list_simcore_service_webserver.workspaces._groups_api.WorkspaceGroupGet__' components: schemas: AccessEnum: @@ -5476,27 +5597,6 @@ components: - ReadOnly type: string description: An enumeration. - AccessRights: - title: AccessRights - required: - - read - - write - - delete - type: object - properties: - read: - title: Read - type: boolean - description: has read access - write: - title: Write - type: boolean - description: has write access - delete: - title: Delete - type: boolean - description: has deletion rights - additionalProperties: false AccountRequestInfo: title: AccountRequestInfo required: @@ -6425,7 +6525,6 @@ components: title: CreateFolderBodyParams required: - name - - description type: object properties: name: @@ -6433,14 +6532,16 @@ components: maxLength: 100 minLength: 1 type: string - description: - title: Description - type: string parentFolderId: title: Parentfolderid exclusiveMinimum: true type: integer minimum: 0 + workspaceId: + title: Workspaceid + exclusiveMinimum: true + type: integer + minimum: 0 additionalProperties: false CreatePricingPlanBodyParams: title: CreatePricingPlanBodyParams @@ -6521,6 +6622,22 @@ components: title: Comment maxLength: 100 type: string + CreateWorkspaceBodyParams: + title: CreateWorkspaceBodyParams + required: + - name + type: object + properties: + name: + title: Name + type: string + description: + title: Description + type: string + thumbnail: + title: Thumbnail + type: string + additionalProperties: false DatCoreFileLink: title: DatCoreFileLink required: @@ -6760,14 +6877,6 @@ components: $ref: '#/components/schemas/FolderGet' error: title: Error - Envelope_FolderGroupGet_: - title: Envelope[FolderGroupGet] - type: object - properties: - data: - $ref: '#/components/schemas/FolderGroupGet' - error: - title: Error Envelope_GetCreditPrice_: title: Envelope[GetCreditPrice] type: object @@ -7145,6 +7254,22 @@ components: $ref: '#/components/schemas/WorkbenchViewApiModel' error: title: Error + Envelope_WorkspaceGet_: + title: Envelope[WorkspaceGet] + type: object + properties: + data: + $ref: '#/components/schemas/WorkspaceGet' + error: + title: Error + Envelope_WorkspaceGroupGet_: + title: Envelope[WorkspaceGroupGet] + type: object + properties: + data: + $ref: '#/components/schemas/WorkspaceGroupGet' + error: + title: Error Envelope__ComputationStarted_: title: Envelope[_ComputationStarted] type: object @@ -7290,8 +7415,8 @@ components: $ref: '#/components/schemas/ClusterGet' error: title: Error - Envelope_list_models_library.api_schemas_webserver.folders.FolderGet__: - title: Envelope[list[models_library.api_schemas_webserver.folders.FolderGet]] + Envelope_list_models_library.api_schemas_webserver.folders_v2.FolderGet__: + title: Envelope[list[models_library.api_schemas_webserver.folders_v2.FolderGet]] type: object properties: data: @@ -7367,6 +7492,17 @@ components: $ref: '#/components/schemas/WalletGetWithAvailableCredits' error: title: Error + Envelope_list_models_library.api_schemas_webserver.workspaces.WorkspaceGet__: + title: Envelope[list[models_library.api_schemas_webserver.workspaces.WorkspaceGet]] + type: object + properties: + data: + title: Data + type: array + items: + $ref: '#/components/schemas/WorkspaceGet' + error: + title: Error Envelope_list_models_library.projects_comments.ProjectsCommentsAPI__: title: Envelope[list[models_library.projects_comments.ProjectsCommentsAPI]] type: object @@ -7401,17 +7537,6 @@ components: $ref: '#/components/schemas/Announcement' error: title: Error - Envelope_list_simcore_service_webserver.folders._groups_api.FolderGroupGet__: - title: Envelope[list[simcore_service_webserver.folders._groups_api.FolderGroupGet]] - type: object - properties: - data: - title: Data - type: array - items: - $ref: '#/components/schemas/FolderGroupGet' - error: - title: Error Envelope_list_simcore_service_webserver.projects._groups_api.ProjectGroupGet__: title: Envelope[list[simcore_service_webserver.projects._groups_api.ProjectGroupGet]] type: object @@ -7555,6 +7680,17 @@ components: $ref: '#/components/schemas/WalletGroupGet' error: title: Error + Envelope_list_simcore_service_webserver.workspaces._groups_api.WorkspaceGroupGet__: + title: Envelope[list[simcore_service_webserver.workspaces._groups_api.WorkspaceGroupGet]] + type: object + properties: + data: + title: Data + type: array + items: + $ref: '#/components/schemas/WorkspaceGroupGet' + error: + title: Error Envelope_str_: title: Envelope[str] type: object @@ -7892,12 +8028,9 @@ components: required: - folderId - name - - description - createdAt - modifiedAt - owner - - myAccessRights - - accessRights type: object properties: folderId: @@ -7913,9 +8046,6 @@ components: name: title: Name type: string - description: - title: Description - type: string createdAt: title: Createdat type: string @@ -7929,46 +8059,6 @@ components: exclusiveMinimum: true type: integer minimum: 0 - myAccessRights: - $ref: '#/components/schemas/AccessRights' - accessRights: - title: Accessrights - type: object - additionalProperties: - $ref: '#/components/schemas/AccessRights' - FolderGroupGet: - title: FolderGroupGet - required: - - gid - - read - - write - - delete - - created - - modified - type: object - properties: - gid: - title: Gid - exclusiveMinimum: true - type: integer - minimum: 0 - read: - title: Read - type: boolean - write: - title: Write - type: boolean - delete: - title: Delete - type: boolean - created: - title: Created - type: string - format: date-time - modified: - title: Modified - type: string - format: date-time GenerateInvitation: title: GenerateInvitation required: @@ -9717,7 +9807,7 @@ components: title: Accessrights type: object additionalProperties: - $ref: '#/components/schemas/AccessRights' + $ref: '#/components/schemas/models_library__projects_access__AccessRights' tags: title: Tags type: array @@ -9730,6 +9820,11 @@ components: type: string ui: $ref: '#/components/schemas/StudyUI' + workspaceId: + title: Workspaceid + exclusiveMinimum: true + type: integer + minimum: 0 ProjectGet: title: ProjectGet required: @@ -9786,7 +9881,7 @@ components: title: Accessrights type: object additionalProperties: - $ref: '#/components/schemas/AccessRights' + $ref: '#/components/schemas/models_library__projects_access__AccessRights' tags: title: Tags type: array @@ -9814,6 +9909,11 @@ components: type: object permalink: $ref: '#/components/schemas/ProjectPermalink' + workspaceId: + title: Workspaceid + exclusiveMinimum: true + type: integer + minimum: 0 ProjectGroupGet: title: ProjectGroupGet required: @@ -10016,7 +10116,7 @@ components: title: Accessrights type: object additionalProperties: - $ref: '#/components/schemas/AccessRights' + $ref: '#/components/schemas/models_library__projects_access__AccessRights' tags: title: Tags type: array @@ -10044,6 +10144,11 @@ components: type: object permalink: $ref: '#/components/schemas/ProjectPermalink' + workspaceId: + title: Workspaceid + exclusiveMinimum: true + type: integer + minimum: 0 ProjectLocked: title: ProjectLocked required: @@ -10163,7 +10268,7 @@ components: title: Accessrights type: object additionalProperties: - $ref: '#/components/schemas/AccessRights' + $ref: '#/components/schemas/models_library__projects_access__AccessRights' classifiers: title: Classifiers type: array @@ -10238,7 +10343,7 @@ components: title: Accessrights type: object additionalProperties: - $ref: '#/components/schemas/AccessRights' + $ref: '#/components/schemas/models_library__projects_access__AccessRights' tags: title: Tags type: array @@ -10350,7 +10455,6 @@ components: title: PutFolderBodyParams required: - name - - description type: object properties: name: @@ -10358,9 +10462,11 @@ components: maxLength: 100 minLength: 1 type: string - description: - title: Description - type: string + parentFolderId: + title: Parentfolderid + exclusiveMinimum: true + type: integer + minimum: 0 additionalProperties: false PutWalletBodyParams: title: PutWalletBodyParams @@ -10380,6 +10486,24 @@ components: type: string status: $ref: '#/components/schemas/WalletStatus' + PutWorkspaceBodyParams: + title: PutWorkspaceBodyParams + required: + - name + type: object + properties: + name: + title: Name + maxLength: 100 + minLength: 1 + type: string + description: + title: Description + type: string + thumbnail: + title: Thumbnail + type: string + additionalProperties: false RegisterBody: title: RegisterBody required: @@ -12312,31 +12436,62 @@ components: allOf: - $ref: '#/components/schemas/TaskCounts' description: task details - _ComputationStarted: - title: _ComputationStarted + WorkspaceGet: + title: WorkspaceGet required: - - pipeline_id + - workspaceId + - name + - createdAt + - modifiedAt + - myAccessRights + - accessRights type: object properties: - pipeline_id: - title: Pipeline Id + workspaceId: + title: Workspaceid + exclusiveMinimum: true + type: integer + minimum: 0 + name: + title: Name type: string - description: ID for created pipeline (=project identifier) - format: uuid - ref_ids: - title: Ref Ids - type: array - items: - type: integer - description: Checkpoints IDs for created pipeline - _FoldersGroupsBodyParams: - title: _FoldersGroupsBodyParams + description: + title: Description + type: string + thumbnail: + title: Thumbnail + type: string + createdAt: + title: Createdat + type: string + format: date-time + modifiedAt: + title: Modifiedat + type: string + format: date-time + myAccessRights: + $ref: '#/components/schemas/models_library__access_rights__AccessRights' + accessRights: + title: Accessrights + type: object + additionalProperties: + $ref: '#/components/schemas/models_library__access_rights__AccessRights' + WorkspaceGroupGet: + title: WorkspaceGroupGet required: + - gid - read - write - delete + - created + - modified type: object properties: + gid: + title: Gid + exclusiveMinimum: true + type: integer + minimum: 0 read: title: Read type: boolean @@ -12346,7 +12501,31 @@ components: delete: title: Delete type: boolean - additionalProperties: false + created: + title: Created + type: string + format: date-time + modified: + title: Modified + type: string + format: date-time + _ComputationStarted: + title: _ComputationStarted + required: + - pipeline_id + type: object + properties: + pipeline_id: + title: Pipeline Id + type: string + description: ID for created pipeline (=project identifier) + format: uuid + ref_ids: + title: Ref Ids + type: array + items: + type: integer + description: Checkpoints IDs for created pipeline _PageParams: title: _PageParams type: object @@ -12442,3 +12621,63 @@ components: title: Delete type: boolean additionalProperties: false + _WorkspacesGroupsBodyParams: + title: _WorkspacesGroupsBodyParams + required: + - read + - write + - delete + type: object + properties: + read: + title: Read + type: boolean + write: + title: Write + type: boolean + delete: + title: Delete + type: boolean + additionalProperties: false + models_library__access_rights__AccessRights: + title: AccessRights + required: + - read + - write + - delete + type: object + properties: + read: + title: Read + type: boolean + description: has read access + write: + title: Write + type: boolean + description: has write access + delete: + title: Delete + type: boolean + description: has deletion rights + additionalProperties: false + models_library__projects_access__AccessRights: + title: AccessRights + required: + - read + - write + - delete + type: object + properties: + read: + title: Read + type: boolean + description: has read access + write: + title: Write + type: boolean + description: has write access + delete: + title: Delete + type: boolean + description: has deletion rights + additionalProperties: false diff --git a/services/web/server/src/simcore_service_webserver/application.py b/services/web/server/src/simcore_service_webserver/application.py index 3c692ae7ace..9e6e4f393d6 100644 --- a/services/web/server/src/simcore_service_webserver/application.py +++ b/services/web/server/src/simcore_service_webserver/application.py @@ -51,6 +51,7 @@ from .users.plugin import setup_users from .version_control.plugin import setup_version_control from .wallets.plugin import setup_wallets +from .workspaces.plugin import setup_workspaces _logger = logging.getLogger(__name__) @@ -126,15 +127,18 @@ def create_application() -> web.Application: setup_resource_manager(app) setup_garbage_collector(app) + # workspaces + setup_workspaces(app) + + # folders + setup_folders(app) + # projects setup_projects(app) # project add-ons setup_version_control(app) setup_meta_modeling(app) - # folders - setup_folders(app) - # tagging setup_scicrunch(app) setup_tags(app) diff --git a/services/web/server/src/simcore_service_webserver/application_settings.py b/services/web/server/src/simcore_service_webserver/application_settings.py index 00804af2155..fcdec0f9eb3 100644 --- a/services/web/server/src/simcore_service_webserver/application_settings.py +++ b/services/web/server/src/simcore_service_webserver/application_settings.py @@ -221,6 +221,7 @@ class ApplicationSettings(BaseCustomSettings, MixinLoggingSettings): WEBSERVER_CLUSTERS: bool = False WEBSERVER_DB_LISTENER: bool = True WEBSERVER_FOLDERS: bool = True + WEBSERVER_WORKSPACES: bool = True WEBSERVER_GROUPS: bool = True WEBSERVER_META_MODELING: bool = True WEBSERVER_NOTIFICATIONS: bool = Field(default=True) diff --git a/services/web/server/src/simcore_service_webserver/application_settings_utils.py b/services/web/server/src/simcore_service_webserver/application_settings_utils.py index 92ac39342e0..9123c8ad574 100644 --- a/services/web/server/src/simcore_service_webserver/application_settings_utils.py +++ b/services/web/server/src/simcore_service_webserver/application_settings_utils.py @@ -181,6 +181,7 @@ def convert_to_app_config(app_settings: ApplicationSettings) -> dict[str, Any]: "version_control": {"enabled": app_settings.WEBSERVER_VERSION_CONTROL}, "wallets": {"enabled": app_settings.WEBSERVER_WALLETS}, "folders": {"enabled": app_settings.WEBSERVER_FOLDERS}, + "workspaces": {"enabled": app_settings.WEBSERVER_WORKSPACES}, } diff --git a/services/web/server/src/simcore_service_webserver/folders/_folders_api.py b/services/web/server/src/simcore_service_webserver/folders/_folders_api.py index ca9d0ce9899..f54b3a9f4c2 100644 --- a/services/web/server/src/simcore_service_webserver/folders/_folders_api.py +++ b/services/web/server/src/simcore_service_webserver/folders/_folders_api.py @@ -1,21 +1,25 @@ # pylint: disable=unused-argument import logging -from typing import cast from aiohttp import web -from aiopg.sa.engine import Engine -from models_library.api_schemas_webserver.folders import FolderGet, FolderGetPage +from models_library.api_schemas_webserver.folders_v2 import FolderGet, FolderGetPage from models_library.folders import FolderID from models_library.products import ProductName -from models_library.projects_access import AccessRights from models_library.rest_ordering import OrderBy -from models_library.users import GroupID, UserID -from pydantic import NonNegativeInt, parse_obj_as -from simcore_postgres_database import utils_folders as folders_db +from models_library.users import UserID +from models_library.workspaces import WorkspaceID +from pydantic import NonNegativeInt +from simcore_service_webserver.workspaces._workspaces_api import ( + check_user_workspace_access, +) -from .._constants import APP_DB_ENGINE_KEY from ..users.api import get_user +from ..workspaces.errors import ( + WorkspaceAccessForbiddenError, + WorkspaceFolderInconsistencyError, +) +from . import _folders_db as folders_db _logger = logging.getLogger(__name__) @@ -23,45 +27,65 @@ async def create_folder( app: web.Application, user_id: UserID, - folder_name: str, - description: str | None, + name: str, parent_folder_id: FolderID | None, product_name: ProductName, + workspace_id: WorkspaceID | None, ) -> FolderGet: user = await get_user(app, user_id=user_id) - engine: Engine = app[APP_DB_ENGINE_KEY] - async with engine.acquire() as connection: - # NOTE: folder permissions are checked inside the function - folder_id = await folders_db.folder_create( - connection, + workspace_is_private = True + if workspace_id: + await check_user_workspace_access( + app, + user_id=user_id, + workspace_id=workspace_id, product_name=product_name, - name=folder_name, - gids={user["primary_gid"]}, - description=description if description else "", - parent=parent_folder_id, + permission="write", ) - folder_db: folders_db.FolderEntry = await folders_db.folder_get( - connection, + workspace_is_private = False + + # Check parent_folder_id lives in the workspace + if parent_folder_id: + parent_folder_db = await folders_db.get( + app, folder_id=parent_folder_id, product_name=product_name + ) + if parent_folder_db.workspace_id != workspace_id: + raise WorkspaceFolderInconsistencyError( + folder_id=parent_folder_id, workspace_id=workspace_id + ) + + if parent_folder_id: + # Check user has access to the parent folder + parent_folder_db = await folders_db.get_for_user_or_workspace( + app, + folder_id=parent_folder_id, product_name=product_name, - folder_id=folder_id, - gids={user["primary_gid"]}, + user_id=user_id if workspace_is_private else None, + workspace_id=workspace_id, ) + if workspace_id and parent_folder_db.workspace_id != workspace_id: + # Check parent folder id exists inside the same workspace + raise WorkspaceAccessForbiddenError( + reason=f"Folder {parent_folder_id} does not exists in workspace {workspace_id}." + ) + + folder_db = await folders_db.create( + app, + product_name=product_name, + created_by_gid=user["primary_gid"], + folder_name=name, + parent_folder_id=parent_folder_id, + user_id=user_id if workspace_is_private else None, + workspace_id=workspace_id, + ) return FolderGet( - folder_id=folder_db.id, - parent_folder_id=folder_db.parent_folder, + folder_id=folder_db.folder_id, + parent_folder_id=folder_db.parent_folder_id, name=folder_db.name, - description=folder_db.description, created_at=folder_db.created, modified_at=folder_db.modified, - owner=folder_db.owner, - my_access_rights=parse_obj_as( - AccessRights, folder_db.my_access_rights.to_dict() - ), - access_rights=parse_obj_as( - dict[GroupID, AccessRights], - {key: value.to_dict() for key, value in folder_db.access_rights.items()}, - ), + owner=folder_db.created_by_gid, ) @@ -71,32 +95,35 @@ async def get_folder( folder_id: FolderID, product_name: ProductName, ) -> FolderGet: - user = await get_user(app, user_id=user_id) + folder_db = await folders_db.get( + app, folder_id=folder_id, product_name=product_name + ) - engine: Engine = app[APP_DB_ENGINE_KEY] - async with engine.acquire() as connection: - # NOTE: folder permissions are checked inside the function - folder_db: folders_db.FolderEntry = await folders_db.folder_get( - connection, + workspace_is_private = True + if folder_db.workspace_id: + await check_user_workspace_access( + app, + user_id=user_id, + workspace_id=folder_db.workspace_id, product_name=product_name, - folder_id=folder_id, - gids={user["primary_gid"]}, + permission="read", ) + workspace_is_private = False + + folder_db = await folders_db.get_for_user_or_workspace( + app, + folder_id=folder_id, + product_name=product_name, + user_id=user_id if workspace_is_private else None, + workspace_id=folder_db.workspace_id, + ) return FolderGet( - folder_id=folder_db.id, - parent_folder_id=folder_db.parent_folder, + folder_id=folder_db.folder_id, + parent_folder_id=folder_db.parent_folder_id, name=folder_db.name, - description=folder_db.description, created_at=folder_db.created, modified_at=folder_db.modified, - owner=folder_db.owner, - my_access_rights=parse_obj_as( - AccessRights, folder_db.my_access_rights.to_dict() - ), - access_rights=parse_obj_as( - dict[GroupID, AccessRights], - {key: value.to_dict() for key, value in folder_db.access_rights.items()}, - ), + owner=folder_db.created_by_gid, ) @@ -105,46 +132,54 @@ async def list_folders( user_id: UserID, product_name: ProductName, folder_id: FolderID | None, + workspace_id: WorkspaceID | None, offset: NonNegativeInt, limit: int, order_by: OrderBy, ) -> FolderGetPage: - user = await get_user(app, user_id=user_id) + workspace_is_private = True - engine: Engine = app[APP_DB_ENGINE_KEY] - async with engine.acquire() as connection: - # NOTE: folder permissions are checked inside the function - total_count, folder_list_db = await folders_db.folder_list( - connection, + if workspace_id: + await check_user_workspace_access( + app, + user_id=user_id, + workspace_id=workspace_id, product_name=product_name, + permission="read", + ) + workspace_is_private = False + + if folder_id: + # Check user access to folder + await folders_db.get_for_user_or_workspace( + app, folder_id=folder_id, - gids={user["primary_gid"]}, - offset=offset, - limit=limit, - order_by=cast(folders_db.OrderByDict, order_by.dict()), + product_name=product_name, + user_id=user_id if workspace_is_private else None, + workspace_id=workspace_id, ) + + total_count, folders = await folders_db.list_( + app, + content_of_folder_id=folder_id, + user_id=user_id if workspace_is_private else None, + workspace_id=workspace_id, + product_name=product_name, + offset=offset, + limit=limit, + order_by=order_by, + ) return FolderGetPage( items=[ FolderGet( - folder_id=folder.id, - parent_folder_id=folder.parent_folder, + folder_id=folder.folder_id, + parent_folder_id=folder.parent_folder_id, name=folder.name, - description=folder.description, created_at=folder.created, modified_at=folder.modified, - owner=folder.owner, - my_access_rights=parse_obj_as( - AccessRights, folder.my_access_rights.to_dict() - ), - access_rights=parse_obj_as( - dict[GroupID, AccessRights], - { - key: value.to_dict() - for key, value in folder.access_rights.items() - }, - ), + owner=folder.created_by_gid, ) - for folder in folder_list_db + for folder in folders ], total=total_count, ) @@ -154,44 +189,50 @@ async def update_folder( app: web.Application, user_id: UserID, folder_id: FolderID, + *, name: str, - description: str | None, + parent_folder_id: FolderID | None, product_name: ProductName, ) -> FolderGet: - user = await get_user(app, user_id=user_id) + folder_db = await folders_db.get( + app, folder_id=folder_id, product_name=product_name + ) - engine: Engine = app[APP_DB_ENGINE_KEY] - async with engine.acquire() as connection: - # NOTE: folder permissions are checked inside the function - await folders_db.folder_update( - connection, + workspace_is_private = True + if folder_db.workspace_id: + await check_user_workspace_access( + app, + user_id=user_id, + workspace_id=folder_db.workspace_id, product_name=product_name, - folder_id=folder_id, - gids={user["primary_gid"]}, - name=name, - description=description, - ) - folder_db: folders_db.FolderEntry = await folders_db.folder_get( - connection, - product_name=product_name, - folder_id=folder_id, - gids={user["primary_gid"]}, + permission="write", ) + workspace_is_private = False + + # Check user has acces to the folder + # NOTE: MD: TODO check function! + await folders_db.get_for_user_or_workspace( + app, + folder_id=folder_id, + product_name=product_name, + user_id=user_id if workspace_is_private else None, + workspace_id=folder_db.workspace_id, + ) + + folder_db = await folders_db.update( + app, + folder_id=folder_id, + name=name, + parent_folder_id=parent_folder_id, + product_name=product_name, + ) return FolderGet( - folder_id=folder_db.id, - parent_folder_id=folder_db.parent_folder, + folder_id=folder_db.folder_id, + parent_folder_id=folder_db.parent_folder_id, name=folder_db.name, - description=folder_db.description, created_at=folder_db.created, modified_at=folder_db.modified, - owner=folder_db.owner, - my_access_rights=parse_obj_as( - AccessRights, folder_db.my_access_rights.to_dict() - ), - access_rights=parse_obj_as( - dict[GroupID, AccessRights], - {key: value.to_dict() for key, value in folder_db.access_rights.items()}, - ), + owner=folder_db.created_by_gid, ) @@ -201,14 +242,28 @@ async def delete_folder( folder_id: FolderID, product_name: ProductName, ) -> None: - user = await get_user(app, user_id=user_id) + folder_db = await folders_db.get( + app, folder_id=folder_id, product_name=product_name + ) - engine: Engine = app[APP_DB_ENGINE_KEY] - async with engine.acquire() as connection: - # NOTE: folder permissions are checked inside the function - await folders_db.folder_delete( - connection, + workspace_is_private = True + if folder_db.workspace_id: + await check_user_workspace_access( + app, + user_id=user_id, + workspace_id=folder_db.workspace_id, product_name=product_name, - folder_id=folder_id, - gids={user["primary_gid"]}, + permission="delete", ) + workspace_is_private = False + + # Check user has acces to the folder + await folders_db.get_for_user_or_workspace( + app, + folder_id=folder_id, + product_name=product_name, + user_id=user_id if workspace_is_private else None, + workspace_id=folder_db.workspace_id, + ) + + await folders_db.delete(app, folder_id=folder_id, product_name=product_name) diff --git a/services/web/server/src/simcore_service_webserver/folders/_folders_db.py b/services/web/server/src/simcore_service_webserver/folders/_folders_db.py new file mode 100644 index 00000000000..8f3016eee78 --- /dev/null +++ b/services/web/server/src/simcore_service_webserver/folders/_folders_db.py @@ -0,0 +1,227 @@ +""" Database API + + - Adds a layer to the postgres API with a focus on the projects comments + +""" + +import logging +from typing import cast + +from aiohttp import web +from models_library.folders import FolderDB, FolderID +from models_library.products import ProductName +from models_library.rest_ordering import OrderBy, OrderDirection +from models_library.users import GroupID, UserID +from models_library.workspaces import WorkspaceID +from pydantic import NonNegativeInt +from simcore_postgres_database.models.folders_v2 import folders_v2 +from sqlalchemy import func +from sqlalchemy.sql import asc, desc, select + +from ..db.plugin import get_database_engine +from .errors import FolderAccessForbiddenError, FolderNotFoundError + +_logger = logging.getLogger(__name__) + + +_SELECTION_ARGS = ( + folders_v2.c.folder_id, + folders_v2.c.name, + folders_v2.c.parent_folder_id, + folders_v2.c.created_by_gid, + folders_v2.c.created, + folders_v2.c.modified, + folders_v2.c.user_id, + folders_v2.c.workspace_id, +) + + +async def create( + app: web.Application, + *, + created_by_gid: GroupID, + folder_name: str, + product_name: ProductName, + parent_folder_id: FolderID | None, + user_id: UserID | None, + workspace_id: WorkspaceID | None, +) -> FolderDB: + assert not ( + user_id is not None and workspace_id is not None + ), "Both user_id and workspace_id cannot be provided at the same time. Please provide only one." + + async with get_database_engine(app).acquire() as conn: + result = await conn.execute( + folders_v2.insert() + .values( + name=folder_name, + parent_folder_id=parent_folder_id, + product_name=product_name, + user_id=user_id, + workspace_id=workspace_id, + created_by_gid=created_by_gid, + created=func.now(), + modified=func.now(), + ) + .returning(*_SELECTION_ARGS) + ) + row = await result.first() + return FolderDB.from_orm(row) + + +async def list_( + app: web.Application, + *, + content_of_folder_id: FolderID | None, + user_id: UserID | None, + workspace_id: WorkspaceID | None, + product_name: ProductName, + offset: NonNegativeInt, + limit: int, + order_by: OrderBy, +) -> tuple[int, list[FolderDB]]: + """ + content_of_folder_id - Used to filter in which folder we want to list folders. None means root folder. + """ + assert not ( + user_id is not None and workspace_id is not None + ), "Both user_id and workspace_id cannot be provided at the same time. Please provide only one." + + base_query = ( + select(*_SELECTION_ARGS) + .select_from(folders_v2) + .where( + (folders_v2.c.product_name == product_name) + & (folders_v2.c.parent_folder_id == content_of_folder_id) + ) + ) + + if user_id: + base_query = base_query.where(folders_v2.c.user_id == user_id) + else: + assert workspace_id # nosec + base_query = base_query.where(folders_v2.c.workspace_id == workspace_id) + + # Select total count from base_query + subquery = base_query.subquery() + count_query = select(func.count()).select_from(subquery) + + # Ordering and pagination + if order_by.direction == OrderDirection.ASC: + list_query = base_query.order_by(asc(getattr(folders_v2.c, order_by.field))) + else: + list_query = base_query.order_by(desc(getattr(folders_v2.c, order_by.field))) + list_query = list_query.offset(offset).limit(limit) + + async with get_database_engine(app).acquire() as conn: + count_result = await conn.execute(count_query) + total_count = await count_result.scalar() + + result = await conn.execute(list_query) + rows = await result.fetchall() or [] + results: list[FolderDB] = [FolderDB.from_orm(row) for row in rows] + return cast(int, total_count), results + + +async def get( + app: web.Application, + *, + folder_id: FolderID, + product_name: ProductName, +) -> FolderDB: + query = ( + select(*_SELECTION_ARGS) + .select_from(folders_v2) + .where( + (folders_v2.c.product_name == product_name) + & (folders_v2.c.folder_id == folder_id) + ) + ) + + async with get_database_engine(app).acquire() as conn: + result = await conn.execute(query) + row = await result.first() + if row is None: + raise FolderAccessForbiddenError( + reason=f"Folder {folder_id} does not exist.", + ) + return FolderDB.from_orm(row) + + +async def get_for_user_or_workspace( + app: web.Application, + *, + folder_id: FolderID, + product_name: ProductName, + user_id: UserID | None, + workspace_id: WorkspaceID | None, +) -> FolderDB: + assert not ( + user_id is not None and workspace_id is not None + ), "Both user_id and workspace_id cannot be provided at the same time. Please provide only one." + + query = ( + select(*_SELECTION_ARGS) + .select_from(folders_v2) + .where( + (folders_v2.c.product_name == product_name) + & (folders_v2.c.folder_id == folder_id) + ) + ) + + if user_id: + query = query.where(folders_v2.c.user_id == user_id) + else: + query = query.where(folders_v2.c.workspace_id == workspace_id) + + async with get_database_engine(app).acquire() as conn: + result = await conn.execute(query) + row = await result.first() + if row is None: + raise FolderAccessForbiddenError( + reason=f"User does not have access to the folder {folder_id}. Or folder does not exist.", + ) + return FolderDB.from_orm(row) + + +async def update( + app: web.Application, + *, + folder_id: FolderID, + name: str, + parent_folder_id: FolderID | None, + product_name: ProductName, +) -> FolderDB: + async with get_database_engine(app).acquire() as conn: + result = await conn.execute( + folders_v2.update() + .values( + name=name, + parent_folder_id=parent_folder_id, + modified=func.now(), + ) + .where( + (folders_v2.c.folder_id == folder_id) + & (folders_v2.c.product_name == product_name) + ) + .returning(*_SELECTION_ARGS) + ) + row = await result.first() + if row is None: + raise FolderNotFoundError(reason=f"Folder {folder_id} not found.") + return FolderDB.from_orm(row) + + +async def delete( + app: web.Application, + *, + folder_id: FolderID, + product_name: ProductName, +) -> None: + async with get_database_engine(app).acquire() as conn: + await conn.execute( + folders_v2.delete().where( + (folders_v2.c.folder_id == folder_id) + & (folders_v2.c.product_name == product_name) + ) + ) diff --git a/services/web/server/src/simcore_service_webserver/folders/_folders_handlers.py b/services/web/server/src/simcore_service_webserver/folders/_folders_handlers.py index 313b83d8c08..1879ba714ad 100644 --- a/services/web/server/src/simcore_service_webserver/folders/_folders_handlers.py +++ b/services/web/server/src/simcore_service_webserver/folders/_folders_handlers.py @@ -2,7 +2,7 @@ import logging from aiohttp import web -from models_library.api_schemas_webserver.folders import ( +from models_library.api_schemas_webserver.folders_v2 import ( CreateFolderBodyParams, FolderGet, FolderGetPage, @@ -15,6 +15,7 @@ from models_library.rest_pagination_utils import paginate_data from models_library.users import UserID from models_library.utils.common_validators import null_or_none_str_to_none_validator +from models_library.workspaces import WorkspaceID from pydantic import Extra, Field, Json, parse_obj_as, validator from servicelib.aiohttp.requests_validation import ( RequestParams, @@ -34,6 +35,11 @@ from ..login.decorators import login_required from ..security.decorators import permission_required from ..utils_aiohttp import envelope_json_response +from ..workspaces.errors import ( + WorkspaceAccessForbiddenError, + WorkspaceFolderInconsistencyError, + WorkspaceNotFoundError, +) from . import _folders_api from .errors import FolderAccessForbiddenError, FolderNotFoundError @@ -46,10 +52,14 @@ async def wrapper(request: web.Request) -> web.StreamResponse: try: return await handler(request) - except FolderNotFoundError as exc: + except (FolderNotFoundError, WorkspaceNotFoundError) as exc: raise web.HTTPNotFound(reason=f"{exc}") from exc - except FolderAccessForbiddenError as exc: + except ( + FolderAccessForbiddenError, + WorkspaceAccessForbiddenError, + WorkspaceFolderInconsistencyError, + ) as exc: raise web.HTTPForbidden(reason=f"{exc}") from exc except FoldersError as exc: @@ -86,6 +96,10 @@ class FolderListWithJsonStrQueryParams(PageQueryParameters): default=None, description="List the subfolders of this folder. By default, list the subfolders of the root directory (Folder ID is None).", ) + workspace_id: WorkspaceID | None = Field( + default=None, + description="List folders in specific workspace. By default, list in the user private workspace", + ) @validator("order_by", check_fields=False) @classmethod @@ -109,6 +123,10 @@ class Config: "folder_id", allow_reuse=True, pre=True )(null_or_none_str_to_none_validator) + _null_or_none_str_to_none_validator2 = validator( + "workspace_id", allow_reuse=True, pre=True + )(null_or_none_str_to_none_validator) + @routes.post(f"/{VTAG}/folders", name="create_folder") @login_required @@ -121,10 +139,10 @@ async def create_folder(request: web.Request): folder = await _folders_api.create_folder( request.app, user_id=req_ctx.user_id, - folder_name=body_params.name, - description=body_params.description, + name=body_params.name, parent_folder_id=body_params.parent_folder_id, product_name=req_ctx.product_name, + workspace_id=body_params.workspace_id, ) return envelope_json_response(folder, web.HTTPCreated) @@ -145,6 +163,7 @@ async def list_folders(request: web.Request): user_id=req_ctx.user_id, product_name=req_ctx.product_name, folder_id=query_params.folder_id, + workspace_id=query_params.workspace_id, offset=query_params.offset, limit=query_params.limit, order_by=parse_obj_as(OrderBy, query_params.order_by), @@ -200,7 +219,7 @@ async def replace_folder(request: web.Request): user_id=req_ctx.user_id, folder_id=path_params.folder_id, name=body_params.name, - description=body_params.description, + parent_folder_id=body_params.parent_folder_id, product_name=req_ctx.product_name, ) return envelope_json_response(folder) diff --git a/services/web/server/src/simcore_service_webserver/folders/_groups_api.py b/services/web/server/src/simcore_service_webserver/folders/_groups_api.py deleted file mode 100644 index 73f100ddc58..00000000000 --- a/services/web/server/src/simcore_service_webserver/folders/_groups_api.py +++ /dev/null @@ -1,80 +0,0 @@ -# pylint: disable=unused-argument - -import logging -from datetime import datetime - -from aiohttp import web -from models_library.folders import FolderID -from models_library.products import ProductName -from models_library.users import GroupID, UserID -from pydantic import BaseModel - -log = logging.getLogger(__name__) - - -class FolderGroupGet(BaseModel): - gid: GroupID - read: bool - write: bool - delete: bool - created: datetime - modified: datetime - - -async def create_folder_group_by_user( - app: web.Application, - *, - user_id: UserID, - folder_id: FolderID, - group_id: GroupID, - read: bool, - write: bool, - delete: bool, - product_name: ProductName, -) -> FolderGroupGet: - raise NotImplementedError - - -async def list_folder_groups_by_user( - app: web.Application, - *, - user_id: UserID, - folder_id: FolderID, - product_name: ProductName, -) -> list[FolderGroupGet]: - raise NotImplementedError - - -async def get_folder_group_by_user( - app: web.Application, - *, - user_id: UserID, - folder_id: FolderID, - product_name: ProductName, -) -> FolderGroupGet: - raise NotImplementedError - - -async def update_folder_group_by_user( - app: web.Application, - *, - user_id: UserID, - folder_id: FolderID, - group_id: GroupID, - read: bool, - write: bool, - delete: bool, - product_name: ProductName, -) -> FolderGroupGet: - raise NotImplementedError - - -async def delete_folder_group_by_user( - app: web.Application, - *, - user_id: UserID, - folder_id: FolderID, - group_id: GroupID, - product_name: ProductName, -) -> None: - raise NotImplementedError diff --git a/services/web/server/src/simcore_service_webserver/folders/plugin.py b/services/web/server/src/simcore_service_webserver/folders/plugin.py index 29a054c990f..bfc0fafb351 100644 --- a/services/web/server/src/simcore_service_webserver/folders/plugin.py +++ b/services/web/server/src/simcore_service_webserver/folders/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 _folders_handlers, _groups_handlers +from . import _folders_handlers _logger = logging.getLogger(__name__) @@ -24,4 +24,3 @@ def setup_folders(app: web.Application): # routes app.router.add_routes(_folders_handlers.routes) - app.router.add_routes(_groups_handlers.routes) diff --git a/services/web/server/src/simcore_service_webserver/garbage_collector/_core_orphans.py b/services/web/server/src/simcore_service_webserver/garbage_collector/_core_orphans.py index 491189039f6..31433088c1d 100644 --- a/services/web/server/src/simcore_service_webserver/garbage_collector/_core_orphans.py +++ b/services/web/server/src/simcore_service_webserver/garbage_collector/_core_orphans.py @@ -15,7 +15,7 @@ from ..director_v2 import api as director_v2_api from ..dynamic_scheduler import api as dynamic_scheduler_api -from ..projects.db import ProjectDBAPI +from ..projects.api import has_user_project_access_rights from ..projects.projects_api import ( is_node_id_present_in_any_project_workbench, list_node_ids_in_project, @@ -41,9 +41,12 @@ async def _remove_service( if await get_user_role(app, service.user_id) <= UserRole.GUEST: save_service_state = False else: - save_service_state = await ProjectDBAPI.get_from_app_context( - app - ).has_permission(service.user_id, f"{service.project_id}", "write") + save_service_state = await has_user_project_access_rights( + app, + project_id=service.project_id, + user_id=service.user_id, + permission="write", + ) except (UserNotFoundError, ValueError): save_service_state = False diff --git a/services/web/server/src/simcore_service_webserver/projects/_access_rights_api.py b/services/web/server/src/simcore_service_webserver/projects/_access_rights_api.py index 40e1b850233..c306e94f4e0 100644 --- a/services/web/server/src/simcore_service_webserver/projects/_access_rights_api.py +++ b/services/web/server/src/simcore_service_webserver/projects/_access_rights_api.py @@ -1,10 +1,15 @@ from aiohttp import web +from models_library.products import ProductName from models_library.projects import ProjectID from models_library.users import UserID +from simcore_service_webserver.projects._db_utils import PermissionStr from ..db.plugin import get_database_engine +from ..workspaces.api import get_workspace from ._access_rights_db import get_project_owner -from .exceptions import ProjectInvalidRightsError +from .db import APP_PROJECT_DBAPI, ProjectDBAPI +from .exceptions import ProjectInvalidRightsError, ProjectNotFoundError +from .models import UserProjectAccessRights async def validate_project_ownership( @@ -19,3 +24,78 @@ async def validate_project_ownership( != user_id ): raise ProjectInvalidRightsError(user_id=user_id, project_uuid=project_uuid) + + +async def get_user_project_access_rights( + app: web.Application, + project_id: ProjectID, + user_id: UserID, + product_name: ProductName, +) -> UserProjectAccessRights: + """ + This function resolves user access rights on the project resource. + + If project belong to user private workspace (workspace_id = None) then it is resolved + via user <--> groups <--> projects_to_groups. + + If project belonsg to shared workspace (workspace_id not None) then it is resolved + via user <--> groups <--> workspace_access_rights + """ + db: ProjectDBAPI = app[APP_PROJECT_DBAPI] + + project_db = await db.get_project_db(project_id) + if project_db.workspace_id: + workspace = await get_workspace( + app, + user_id=user_id, + workspace_id=project_db.workspace_id, + product_name=product_name, + ) + _user_project_access_rights = UserProjectAccessRights( + uid=user_id, + read=workspace.my_access_rights.read, + write=workspace.my_access_rights.write, + delete=workspace.my_access_rights.delete, + ) + else: + _user_project_access_rights = ( + await db.get_pure_project_access_rights_without_workspace( + user_id, project_id + ) + ) + return _user_project_access_rights + + +async def has_user_project_access_rights( + app: web.Application, + *, + project_id: ProjectID, + user_id: UserID, + permission: PermissionStr, +) -> bool: + try: + db: ProjectDBAPI = app[APP_PROJECT_DBAPI] + product_name = await db.get_project_product(project_uuid=project_id) + + prj_access_rights = await get_user_project_access_rights( + app, project_id=project_id, user_id=user_id, product_name=product_name + ) + return getattr(prj_access_rights, permission, False) is not False + except (ProjectInvalidRightsError, ProjectNotFoundError): + return False + + +async def check_user_project_permission( + app: web.Application, + *, + project_id: ProjectID, + user_id: UserID, + product_name: ProductName, + permission: PermissionStr = "read", +) -> UserProjectAccessRights: + _user_project_access_rights = await get_user_project_access_rights( + app, project_id=project_id, user_id=user_id, product_name=product_name + ) + if getattr(_user_project_access_rights, permission, False) is False: + raise ProjectInvalidRightsError(user_id=user_id, project_uuid=project_id) + return _user_project_access_rights diff --git a/services/web/server/src/simcore_service_webserver/projects/_crud_api_create.py b/services/web/server/src/simcore_service_webserver/projects/_crud_api_create.py index 1d444d0d940..780c976bd45 100644 --- a/services/web/server/src/simcore_service_webserver/projects/_crud_api_create.py +++ b/services/web/server/src/simcore_service_webserver/projects/_crud_api_create.py @@ -14,6 +14,7 @@ from models_library.users import UserID from models_library.utils.fastapi_encoders import jsonable_encoder from models_library.utils.json_serialization import json_dumps +from models_library.workspaces import WorkspaceID from pydantic import parse_obj_as from servicelib.aiohttp.long_running_tasks.server import TaskProgress from servicelib.mimetype_constants import MIMETYPE_APPLICATION_JSON @@ -31,6 +32,8 @@ get_project_total_size_simcore_s3, ) from ..users.api import get_user_fullname +from ..workspaces.api import get_workspace +from ..workspaces.errors import WorkspaceAccessForbiddenError from . import projects_api from ._metadata_api import set_project_ancestors from ._permalink_api import update_or_pop_permalink_in_project @@ -216,7 +219,7 @@ async def _compose_project_data( return new_project, project_nodes -async def create_project( # pylint: disable=too-many-arguments # noqa: C901, PLR0913 +async def create_project( # pylint: disable=too-many-arguments,too-many-branches,too-many-statements # noqa: C901, PLR0913 task_progress: TaskProgress, *, request: web.Request, @@ -230,6 +233,7 @@ async def create_project( # pylint: disable=too-many-arguments # noqa: C901, P simcore_user_agent: str, parent_project_uuid: ProjectID | None, parent_node_id: NodeID | None, + workspace_id: WorkspaceID | None, ) -> None: """Implements TaskProtocol for 'create_projects' handler @@ -286,6 +290,20 @@ async def create_project( # pylint: disable=too-many-arguments # noqa: C901, P predefined_project=predefined_project, ) + # If user wants to create project in specific workspace + if workspace_id: + # Verify user access to the specified workspace; raise an error if access is denied + workspace = await get_workspace( + request.app, + user_id=user_id, + workspace_id=workspace_id, + product_name=product_name, + ) + if workspace.my_access_rights.write is False: + raise WorkspaceAccessForbiddenError( + reason=f"User {user_id} does not have write permission on workspace {workspace_id}." + ) + # 3. save new project in DB new_project = await db.insert_project( project=jsonable_encoder(new_project), @@ -325,9 +343,7 @@ async def create_project( # pylint: disable=too-many-arguments # noqa: C901, P request.app, user_id, new_project["uuid"], product_name ) # get the latest state of the project (lastChangeDate for instance) - new_project, _ = await db.get_project( - user_id=user_id, project_uuid=new_project["uuid"] - ) + new_project, _ = await db.get_project(project_uuid=new_project["uuid"]) # Appends state new_project = await projects_api.add_project_states_for_user( user_id=user_id, @@ -354,7 +370,7 @@ async def create_project( # pylint: disable=too-many-arguments # noqa: C901, P except ProjectNotFoundError as exc: raise web.HTTPNotFound(reason=f"Project {exc.project_uuid} not found") from exc - except ProjectInvalidRightsError as exc: + except (ProjectInvalidRightsError, WorkspaceAccessForbiddenError) as exc: raise web.HTTPForbidden from exc except (ParentProjectNotFoundError, ParentNodeNotFoundError) as exc: diff --git a/services/web/server/src/simcore_service_webserver/projects/_crud_api_delete.py b/services/web/server/src/simcore_service_webserver/projects/_crud_api_delete.py index 0ee6ac2e222..8fa770ddae9 100644 --- a/services/web/server/src/simcore_service_webserver/projects/_crud_api_delete.py +++ b/services/web/server/src/simcore_service_webserver/projects/_crud_api_delete.py @@ -17,6 +17,7 @@ from ..storage.api import delete_data_folders_of_project from ..users.api import FullNameDict from ..users.exceptions import UserNotFoundError +from ._access_rights_api import check_user_project_permission from .db import ProjectDBAPI from .exceptions import ( ProjectDeleteError, @@ -56,7 +57,14 @@ async def mark_project_as_deleted( """ # NOTE: https://github.com/ITISFoundation/osparc-issues/issues/468 db: ProjectDBAPI = ProjectDBAPI.get_from_app_context(app) - await db.check_delete_project_permission(user_id, f"{project_uuid}") + product_name = await db.get_project_product(project_uuid=project_uuid) + await check_user_project_permission( + app, + project_id=project_uuid, + user_id=user_id, + product_name=product_name, + permission="delete", + ) await db.check_project_has_only_one_product(project_uuid) @@ -88,6 +96,7 @@ async def delete_project( db: ProjectDBAPI = ProjectDBAPI.get_from_app_context(app) try: + # NOTE: Project access rights are checked inside this function await mark_project_as_deleted(app, project_uuid, user_id) # stops dynamic services @@ -115,7 +124,7 @@ async def delete_project( project_uuid=project_uuid, reason=f"Project currently in use {err}" ) from err - except (ProjectNotFoundError, UserNotFoundError) as err: + except (ProjectInvalidRightsError, ProjectNotFoundError, UserNotFoundError) as err: raise ProjectDeleteError( project_uuid=project_uuid, reason=f"Invalid project state {err}" ) from err diff --git a/services/web/server/src/simcore_service_webserver/projects/_crud_api_read.py b/services/web/server/src/simcore_service_webserver/projects/_crud_api_read.py index 44df76765aa..2c9df781336 100644 --- a/services/web/server/src/simcore_service_webserver/projects/_crud_api_read.py +++ b/services/web/server/src/simcore_service_webserver/projects/_crud_api_read.py @@ -12,12 +12,16 @@ from models_library.projects import ProjectID from models_library.rest_ordering import OrderBy from models_library.users import UserID +from models_library.workspaces import WorkspaceID from pydantic import NonNegativeInt from servicelib.utils import logged_gather from simcore_postgres_database.webserver_models import ProjectType as ProjectTypeDB +from simcore_service_webserver.workspaces._workspaces_api import ( + check_user_workspace_access, +) -from ..application_settings import get_application_settings from ..catalog.client import get_services_for_user_in_product +from ..folders import _folders_db as folders_db from . import projects_api from ._permalink_api import update_or_pop_permalink_in_project from .db import ProjectDBAPI @@ -47,7 +51,7 @@ async def _append_fields( return model_schema_cls.parse_obj(project).data(exclude_unset=True) -async def list_projects( +async def list_projects( # pylint: disable=too-many-arguments request: web.Request, user_id: UserID, product_name: str, @@ -58,19 +62,39 @@ async def list_projects( search: str | None, order_by: OrderBy, folder_id: FolderID | None, + workspace_id: WorkspaceID | None, ) -> tuple[list[ProjectDict], int]: app = request.app - settings = get_application_settings(app) db = ProjectDBAPI.get_from_app_context(app) user_available_services: list[dict] = await get_services_for_user_in_product( app, user_id, product_name, only_key_versions=True ) + workspace_is_private = True + if workspace_id: + await check_user_workspace_access( + app, + user_id=user_id, + workspace_id=workspace_id, + product_name=product_name, + permission="read", + ) + workspace_is_private = False + + if folder_id: + # Check whether user has access to the folder + await folders_db.get_for_user_or_workspace( + app, + folder_id=folder_id, + product_name=product_name, + user_id=user_id if workspace_is_private else None, + workspace_id=workspace_id, + ) + db_projects, db_project_types, total_number_projects = await db.list_projects( - user_id=user_id, product_name=product_name, - settings=settings, + user_id=user_id, filter_by_project_type=ProjectTypeAPI.to_project_type_db(project_type), filter_by_services=user_available_services, offset=offset, @@ -79,6 +103,7 @@ async def list_projects( search=search, order_by=order_by, folder_id=folder_id, + workspace_id=workspace_id, ) projects: list[ProjectDict] = await logged_gather( diff --git a/services/web/server/src/simcore_service_webserver/projects/_crud_handlers.py b/services/web/server/src/simcore_service_webserver/projects/_crud_handlers.py index 79186150c90..16e7f9574ad 100644 --- a/services/web/server/src/simcore_service_webserver/projects/_crud_handlers.py +++ b/services/web/server/src/simcore_service_webserver/projects/_crud_handlers.py @@ -44,12 +44,15 @@ from .._meta import API_VTAG as VTAG from ..catalog.client import get_services_for_user_in_product from ..director_v2 import api +from ..folders.errors import FolderAccessForbiddenError, FolderNotFoundError from ..login.decorators import login_required from ..resource_manager.user_sessions import PROJECT_ID_KEY, managed_resource from ..security.api import check_user_permission from ..security.decorators import permission_required from ..users.api import get_user_fullname +from ..workspaces.errors import WorkspaceAccessForbiddenError, WorkspaceNotFoundError from . import _crud_api_create, _crud_api_read, projects_api +from ._access_rights_api import check_user_project_permission from ._common_models import ProjectPathParams, RequestContext from ._crud_handlers_models import ( ProjectActiveParams, @@ -91,11 +94,19 @@ async def _wrapper(request: web.Request) -> web.StreamResponse: try: return await handler(request) - except ProjectNotFoundError as exc: + except ( + ProjectNotFoundError, + FolderNotFoundError, + WorkspaceNotFoundError, + ) as exc: raise web.HTTPNotFound(reason=f"{exc}") from exc except ProjectOwnerNotFoundInTheProjectAccessRightsError as exc: raise web.HTTPBadRequest(reason=f"{exc}") from exc - except ProjectInvalidRightsError as exc: + except ( + ProjectInvalidRightsError, + FolderAccessForbiddenError, + WorkspaceAccessForbiddenError, + ) as exc: raise web.HTTPUnauthorized(reason=f"{exc}") from exc return _wrapper @@ -127,6 +138,7 @@ async def create_project(request: web.Request): # :create, :copy (w/ and w/o override) # NOTE: see clone_project + workspace_id = None if not request.can_read_body: # request w/o body predefined_project = None @@ -146,6 +158,10 @@ async def create_project(request: web.Request): or None ) + # # Manually include workspace after exclude + # workspace_id = project_create.dict(by_alias=True).get("workspaceId", None) + # predefined_project["workspaceId"] = workspace_id + return await start_long_running_task( request, _crud_api_create.create_project, # type: ignore[arg-type] # @GitHK, @pcrespov this one I don't know how to fix @@ -163,6 +179,7 @@ async def create_project(request: web.Request): predefined_project=predefined_project, parent_project_uuid=header_params.parent_project_uuid, parent_node_id=header_params.parent_node_id, + workspace_id=workspace_id, ) @@ -173,6 +190,7 @@ async def create_project(request: web.Request): @routes.get(f"/{VTAG}/projects", name="list_projects") @login_required @permission_required("project.read") +@_handle_projects_exceptions async def list_projects(request: web.Request): """ @@ -196,6 +214,7 @@ async def list_projects(request: web.Request): search=query_params.search, order_by=parse_obj_as(OrderBy, query_params.order_by), folder_id=query_params.folder_id, + workspace_id=query_params.workspace_id, ) page = Page[ProjectDict].parse_obj( @@ -386,6 +405,7 @@ async def replace_project(request: web.Request): "project.update | project.workbench.node.inputs.update", context={ "dbapi": db, + "app": request.app, "project_id": f"{path_params.project_id}", "user_id": req_ctx.user_id, "new_data": new_project, @@ -430,6 +450,14 @@ async def replace_project(request: web.Request): reason=f"Project {path_params.project_id} cannot be modified while pipeline is still running." ) + await check_user_project_permission( + request.app, + project_id=path_params.project_id, + user_id=req_ctx.user_id, + product_name=req_ctx.product_name, + permission="write", + ) + new_project = await db.replace_project( new_project, req_ctx.user_id, @@ -603,6 +631,12 @@ async def clone_project(request: web.Request): req_ctx = RequestContext.parse_obj(request) path_params = parse_request_path_parameters_as(ProjectPathParams, request) + db: ProjectDBAPI = ProjectDBAPI.get_from_app_context(request.app) + try: + project_db = await db.get_project_db(path_params.project_id) + except ProjectNotFoundError as exc: + raise web.HTTPNotFound(reason=f"Project {exc.project_uuid} not found") from exc + return await start_long_running_task( request, _crud_api_create.create_project, # type: ignore[arg-type] # @GitHK, @pcrespov this one I don't know how to fix @@ -622,4 +656,5 @@ async def clone_project(request: web.Request): predefined_project=None, parent_project_uuid=None, parent_node_id=None, + workspace_id=project_db.workspace_id, ) diff --git a/services/web/server/src/simcore_service_webserver/projects/_crud_handlers_models.py b/services/web/server/src/simcore_service_webserver/projects/_crud_handlers_models.py index 0fc3489c8a6..6e89c6eb7c8 100644 --- a/services/web/server/src/simcore_service_webserver/projects/_crud_handlers_models.py +++ b/services/web/server/src/simcore_service_webserver/projects/_crud_handlers_models.py @@ -13,6 +13,7 @@ from models_library.rest_ordering import OrderBy, OrderDirection from models_library.rest_pagination import PageQueryParameters from models_library.utils.common_validators import null_or_none_str_to_none_validator +from models_library.workspaces import WorkspaceID from pydantic import BaseModel, Extra, Field, Json, root_validator, validator from servicelib.common_headers import ( UNDEFINED_DEFAULT_SIMCORE_USER_AGENT_VALUE, @@ -98,6 +99,10 @@ class ProjectListParams(PageQueryParameters): default=None, description="Filter projects in specific folder. Default filtering is a root directory.", ) + workspace_id: WorkspaceID | None = Field( + default=None, + description="Filter projects in specific workspace. Default filtering is a private workspace.", + ) @validator("search", pre=True) @classmethod @@ -110,6 +115,10 @@ def search_check_empty_string(cls, v): "folder_id", allow_reuse=True, pre=True )(null_or_none_str_to_none_validator) + _null_or_none_str_to_none_validator2 = validator( + "workspace_id", allow_reuse=True, pre=True + )(null_or_none_str_to_none_validator) + class ProjectListWithJsonStrParams(ProjectListParams): order_by: Json[OrderBy] = Field( # pylint: disable=unsubscriptable-object 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 7a8e2f9c064..14a0eae1306 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 @@ -31,7 +31,7 @@ ProjectInvalidUsageError, ProjectNotFoundError, ) -from .models import ProjectDict, ProjectProxy +from .models import ProjectDict from .utils import find_changed_node_keys, project_uses_available_services logger = logging.getLogger(__name__) @@ -51,88 +51,6 @@ class ProjectAccessRights(Enum): VIEWER = {"read": True, "write": False, "delete": False} -def check_project_permissions( - project: ProjectProxy | ProjectDict, - user_id: int, - user_groups: list[dict[str, Any]] | list[RowProxy], - permission: str, -) -> None: - """ - :raises ProjectInvalidRightsError if check fails - """ - - if not permission: - return - - operations_on_project = set(permission.split("|")) - assert set(operations_on_project).issubset(set(PermissionStr.__args__)) # type: ignore[attr-defined] # nosec - - # - # Get primary_gid, standard_gids and everyone_gid for user_id - # - all_group = next( - filter(lambda x: x.get("type") == GroupType.EVERYONE, user_groups), None - ) - if all_group is None: - raise ProjectInvalidRightsError( - user_id=user_id, project_uuid=project.get("uuid") - ) - - everyone_gid = str(all_group["gid"]) - - if user_id == ANY_USER_ID_SENTINEL: - primary_gid = None - standard_gids = [] - - else: - primary_group = next( - filter(lambda x: x.get("type") == GroupType.PRIMARY, user_groups), None - ) - if primary_group is None: - # the user groups is missing entries - raise ProjectInvalidRightsError( - user_id=user_id, project_uuid=project.get("uuid") - ) - - standard_groups = filter( - lambda x: x.get("type") == GroupType.STANDARD, user_groups - ) - - primary_gid = str(primary_group["gid"]) - standard_gids = [str(group["gid"]) for group in standard_groups] - - # - # Composes access rights by order of priority all group > organizations > primary - # - project_access_rights = deepcopy(project.get("access_rights", {})) - - # access rights for everyone - user_can = project_access_rights.get( - everyone_gid, {"read": False, "write": False, "delete": False} - ) - - # access rights for standard groups - for group_id in standard_gids: - standard_project_access = project_access_rights.get( - group_id, {"read": False, "write": False, "delete": False} - ) - for operation in user_can: - user_can[operation] = ( - user_can[operation] or standard_project_access[operation] - ) - # access rights for primary group - primary_access_right = project_access_rights.get( - primary_gid, {"read": False, "write": False, "delete": False} - ) - for operation in user_can: - user_can[operation] = user_can[operation] or primary_access_right[operation] - - if any(not user_can[operation] for operation in operations_on_project): - raise ProjectInvalidRightsError( - user_id=user_id, project_uuid=project.get("uuid") - ) - - def create_project_access_rights( gid: int, access: ProjectAccessRights ) -> dict[str, dict[str, bool]]: @@ -213,7 +131,10 @@ async def _list_user_groups( result = await conn.execute( select(groups) .select_from(groups.join(user_to_groups)) - .where(user_to_groups.c.uid == user_id) + .where( + (user_to_groups.c.uid == user_id) + & (user_to_groups.c.access_rights["read"].astext == "true") + ) ) user_groups = await result.fetchall() or [] return user_groups @@ -257,13 +178,12 @@ async def _upsert_tags_in_project( .on_conflict_do_nothing() ) - async def _execute_with_permission_check( + async def _execute_without_permission_check( self, conn: SAConnection, + user_id: UserID, *, select_projects_query: Select, - user_id: int, - user_groups: list[RowProxy], filter_by_services: list[dict] | None = None, ) -> tuple[list[dict[str, Any]], list[ProjectType]]: api_projects: list[dict] = [] # API model-compatible projects @@ -272,8 +192,6 @@ async def _execute_with_permission_check( async for row in conn.execute(select_projects_query): assert isinstance(row, RowProxy) # nosec try: - check_project_permissions(row, user_id, user_groups, "read") - await asyncio.get_event_loop().run_in_executor( None, ProjectAtDB.from_orm, row ) @@ -322,23 +240,18 @@ async def _execute_with_permission_check( async def _get_project( self, connection: SAConnection, - user_id: UserID, project_uuid: str, + *, exclude_foreign: list[str] | None = None, for_update: bool = False, only_templates: bool = False, only_published: bool = False, - check_permissions: PermissionStr = "read", ) -> dict: """ raises ProjectNotFoundError if project does not exists - raises ProjectInvalidRightsError if user_id does not have at 'check_permissions' access rights """ exclude_foreign = exclude_foreign or [] - # this retrieves the projects where user is owner - user_groups: list[RowProxy] = await self._list_user_groups(connection, user_id) - access_rights_subquery = ( select( project_to_groups.c.project_uuid, @@ -360,7 +273,7 @@ async def _get_project( query = ( sa.select( - *[col for col in projects.columns if col.name != "access_rights"], + *[col for col in projects.columns if col.name not in ["access_rights"]], access_rights_subquery.c.access_rights, ) .select_from(projects.join(access_rights_subquery, isouter=True)) @@ -391,13 +304,7 @@ async def _get_project( if not project_row: raise ProjectNotFoundError( project_uuid=project_uuid, - search_context=f"{user_id=}, {only_templates=}, {only_published=}, {check_permissions=}", - ) - - # check the access rights - if user_id: - check_project_permissions( - project_row, user_id, user_groups, check_permissions + search_context=f"{only_templates=}, {only_published=}", ) project: dict[str, Any] = dict(project_row.items()) diff --git a/services/web/server/src/simcore_service_webserver/projects/_folders_api.py b/services/web/server/src/simcore_service_webserver/projects/_folders_api.py index 8a3a1539253..d2107b46499 100644 --- a/services/web/server/src/simcore_service_webserver/projects/_folders_api.py +++ b/services/web/server/src/simcore_service_webserver/projects/_folders_api.py @@ -1,23 +1,21 @@ import logging from aiohttp import web -from aiopg.sa.engine import Engine from models_library.folders import FolderID from models_library.products import ProductName from models_library.projects import ProjectID from models_library.users import UserID -from simcore_postgres_database import utils_folders as folders_db -from simcore_service_webserver.projects.models import UserProjectAccessRights -from .._constants import APP_DB_ENGINE_KEY -from ..users.api import get_user +from ..folders import _folders_db as folders_db +from ..projects._access_rights_api import get_user_project_access_rights +from . import _folders_db as project_to_folders_db from .db import APP_PROJECT_DBAPI, ProjectDBAPI from .exceptions import ProjectInvalidRightsError _logger = logging.getLogger(__name__) -async def replace_project_folder( +async def move_project_into_folder( app: web.Application, *, user_id: UserID, @@ -25,45 +23,66 @@ async def replace_project_folder( folder_id: FolderID | None, product_name: ProductName, ) -> None: - project_db: ProjectDBAPI = app[APP_PROJECT_DBAPI] - project_access_rights: UserProjectAccessRights = ( - await project_db.get_project_access_rights_for_user( - user_id=user_id, project_uuid=project_id - ) + project_api: ProjectDBAPI = app[APP_PROJECT_DBAPI] + project_db = await project_api.get_project_db(project_id) + + # Check access to project + project_access_rights = await get_user_project_access_rights( + app, project_id=project_id, user_id=user_id, product_name=product_name ) - if project_access_rights.write is False: - raise ProjectInvalidRightsError( - user_id=user_id, - project_uuid=project_id, - reason=f"User does not have write access to project {project_id}", - ) - user = await get_user(app, user_id=user_id) - engine: Engine = app[APP_DB_ENGINE_KEY] - async with engine.acquire() as connection: - _source_folder_id = await folders_db.get_project_folder_without_check( - connection, - project_uuid=project_id, - ) - if _source_folder_id is None: - assert folder_id is not None # nosec - # NOTE: folder permissions are checked inside the function - await folders_db.folder_add_project( - connection, - product_name=product_name, - folder_id=folder_id, - gids={user["primary_gid"]}, + # In private workspace user can move as he wish, but in the + # shared workspace user needs to have write permission + workspace_is_private = True + if project_db.workspace_id is not None: # shared workspace + if project_access_rights.write is False: + raise ProjectInvalidRightsError( + user_id=user_id, project_uuid=project_id, + reason=f"User does not have write access to project {project_id}", ) - return + workspace_is_private = False - # NOTE: folder permissions are checked inside the function - await folders_db.folder_move_project( - connection, + if folder_id: + # Check user has access to folder + await folders_db.get_for_user_or_workspace( + app, + folder_id=folder_id, product_name=product_name, - source_folder_id=_source_folder_id, - gids={user["primary_gid"]}, - project_uuid=project_id, - destination_folder_id=folder_id, + user_id=user_id if workspace_is_private else None, + workspace_id=project_db.workspace_id, ) - return + + # Move project to folder + prj_to_folder_db = await project_to_folders_db.get_project_to_folder( + app, + project_id=project_id, + private_workspace_user_id_or_none=user_id if workspace_is_private else None, + ) + if prj_to_folder_db is None: + if folder_id is None: + return + await project_to_folders_db.insert_project_to_folder( + app, + project_id=project_id, + folder_id=folder_id, + private_workspace_user_id_or_none=user_id if workspace_is_private else None, + ) + else: + # Delete old + await project_to_folders_db.delete_project_to_folder( + app, + project_id=project_id, + folder_id=prj_to_folder_db.folder_id, + private_workspace_user_id_or_none=user_id if workspace_is_private else None, + ) + # Create new + if folder_id is not None: + await project_to_folders_db.insert_project_to_folder( + app, + project_id=project_id, + folder_id=folder_id, + private_workspace_user_id_or_none=user_id + if workspace_is_private + else None, + ) diff --git a/services/web/server/src/simcore_service_webserver/projects/_folders_db.py b/services/web/server/src/simcore_service_webserver/projects/_folders_db.py new file mode 100644 index 00000000000..744f34d71eb --- /dev/null +++ b/services/web/server/src/simcore_service_webserver/projects/_folders_db.py @@ -0,0 +1,100 @@ +""" Database API + + - Adds a layer to the postgres API with a focus on the projects comments + +""" + +import logging +from datetime import datetime + +from aiohttp import web +from models_library.folders import FolderID +from models_library.projects import ProjectID +from models_library.users import UserID +from pydantic import BaseModel, parse_obj_as +from simcore_postgres_database.models.projects_to_folders import projects_to_folders +from sqlalchemy import func, literal_column +from sqlalchemy.sql import select + +from ..db.plugin import get_database_engine + +_logger = logging.getLogger(__name__) + + +_logger = logging.getLogger(__name__) + +### Models + + +class ProjectToFolderDB(BaseModel): + project_uuid: ProjectID + folder_id: FolderID + user_id: UserID | None + created: datetime + modified: datetime + + +## DB API + + +async def insert_project_to_folder( + app: web.Application, + project_id: ProjectID, + folder_id: FolderID, + private_workspace_user_id_or_none: UserID | None, +) -> ProjectToFolderDB: + async with get_database_engine(app).acquire() as conn: + result = await conn.execute( + projects_to_folders.insert() + .values( + project_uuid=f"{project_id}", + folder_id=folder_id, + user_id=private_workspace_user_id_or_none, + created=func.now(), + modified=func.now(), + ) + .returning(literal_column("*")) + ) + row = await result.first() + return parse_obj_as(ProjectToFolderDB, row) + + +async def get_project_to_folder( + app: web.Application, + *, + project_id: ProjectID, + private_workspace_user_id_or_none: UserID | None, +) -> ProjectToFolderDB | None: + stmt = select( + projects_to_folders.c.project_uuid, + projects_to_folders.c.folder_id, + projects_to_folders.c.user_id, + projects_to_folders.c.created, + projects_to_folders.c.modified, + ).where( + (projects_to_folders.c.project_uuid == f"{project_id}") + & (projects_to_folders.c.user_id == private_workspace_user_id_or_none) + ) + + async with get_database_engine(app).acquire() as conn: + result = await conn.execute(stmt) + row = await result.first() + if row is None: + return None + return parse_obj_as(ProjectToFolderDB, row) + + +async def delete_project_to_folder( + app: web.Application, + project_id: ProjectID, + folder_id: FolderID, + private_workspace_user_id_or_none: UserID | None, +) -> None: + async with get_database_engine(app).acquire() as conn: + await conn.execute( + projects_to_folders.delete().where( + (projects_to_folders.c.project_uuid == f"{project_id}") + & (projects_to_folders.c.folder_id == folder_id) + & (projects_to_folders.c.user_id == private_workspace_user_id_or_none) + ) + ) diff --git a/services/web/server/src/simcore_service_webserver/projects/_folders_handlers.py b/services/web/server/src/simcore_service_webserver/projects/_folders_handlers.py index 303a857c6d5..591fecf8a94 100644 --- a/services/web/server/src/simcore_service_webserver/projects/_folders_handlers.py +++ b/services/web/server/src/simcore_service_webserver/projects/_folders_handlers.py @@ -11,10 +11,9 @@ from servicelib.mimetype_constants import MIMETYPE_APPLICATION_JSON from .._meta import api_version_prefix as VTAG -from ..application_settings import get_application_settings from ..login.decorators import login_required from ..security.decorators import permission_required -from . import _folders_api, projects_api +from . import _folders_api from ._common_models import RequestContext from .exceptions import ProjectGroupNotFoundError, ProjectNotFoundError @@ -63,19 +62,7 @@ async def replace_project_folder(request: web.Request): req_ctx = RequestContext.parse_obj(request) path_params = parse_request_path_parameters_as(_ProjectsFoldersPathParams, request) - settings = get_application_settings(request.app) - if not settings.WEBSERVER_FOLDERS: - raise RuntimeError("Webserver folders plugin disabled") - - # ensure the project exists - await projects_api.get_project_for_user( - request.app, - project_uuid=f"{path_params.project_id}", - user_id=req_ctx.user_id, - include_state=False, - ) - - await _folders_api.replace_project_folder( + await _folders_api.move_project_into_folder( app=request.app, user_id=req_ctx.user_id, project_id=path_params.project_id, diff --git a/services/web/server/src/simcore_service_webserver/projects/_groups_api.py b/services/web/server/src/simcore_service_webserver/projects/_groups_api.py index 5048c1b6a01..db84993fdb7 100644 --- a/services/web/server/src/simcore_service_webserver/projects/_groups_api.py +++ b/services/web/server/src/simcore_service_webserver/projects/_groups_api.py @@ -6,10 +6,10 @@ from models_library.projects import ProjectID from models_library.users import GroupID, UserID from pydantic import BaseModel, parse_obj_as -from simcore_service_webserver.projects.models import UserProjectAccessRights from ..users import api as users_api from . import _groups_db as projects_groups_db +from ._access_rights_api import check_user_project_permission from ._groups_db import ProjectGroupGetDB from .db import APP_PROJECT_DBAPI, ProjectDBAPI from .exceptions import ProjectInvalidRightsError @@ -35,21 +35,15 @@ async def create_project_group( read: bool, write: bool, delete: bool, - product_name: ProductName, # pylint: disable=unused-argument + product_name: ProductName, ) -> ProjectGroupGet: - project_db: ProjectDBAPI = app[APP_PROJECT_DBAPI] - - project_access_rights: UserProjectAccessRights = ( - await project_db.get_project_access_rights_for_user( - user_id=user_id, project_uuid=project_id - ) + await check_user_project_permission( + app, + project_id=project_id, + user_id=user_id, + product_name=product_name, + permission="write", ) - if project_access_rights.write is False: - raise ProjectInvalidRightsError( - user_id=user_id, - project_uuid=project_id, - reason=f"User does not have write access to project {project_id}", - ) project_group_db: ProjectGroupGetDB = await projects_groups_db.create_project_group( app=app, @@ -69,21 +63,15 @@ async def list_project_groups_by_user_and_project( *, user_id: UserID, project_id: ProjectID, - product_name: ProductName, # pylint: disable=unused-argument + product_name: ProductName, ) -> list[ProjectGroupGet]: - project_db: ProjectDBAPI = app[APP_PROJECT_DBAPI] - - project_access_rights: UserProjectAccessRights = ( - await project_db.get_project_access_rights_for_user( - user_id=user_id, project_uuid=project_id - ) + await check_user_project_permission( + app, + project_id=project_id, + user_id=user_id, + product_name=product_name, + permission="read", ) - if project_access_rights.read is False: - raise ProjectInvalidRightsError( - user_id=user_id, - project_uuid=project_id, - reason=f"User does not have read access to project {project_id}", - ) project_groups_db: list[ ProjectGroupGetDB @@ -105,22 +93,17 @@ async def replace_project_group( read: bool, write: bool, delete: bool, - product_name: ProductName, # pylint: disable=unused-argument + product_name: ProductName, ) -> ProjectGroupGet: - project_db: ProjectDBAPI = app[APP_PROJECT_DBAPI] - - project_access_rights: UserProjectAccessRights = ( - await project_db.get_project_access_rights_for_user( - user_id=user_id, project_uuid=project_id - ) + await check_user_project_permission( + app, + project_id=project_id, + user_id=user_id, + product_name=product_name, + permission="write", ) - if project_access_rights.write is False: - raise ProjectInvalidRightsError( - user_id=user_id, - project_uuid=project_id, - reason=f"User does not have write access to project {project_id}", - ) + project_db: ProjectDBAPI = app[APP_PROJECT_DBAPI] project = await project_db.get_project_db(project_id) project_owner_user: dict = await users_api.get_user(app, project.prj_owner) if project_owner_user["primary_gid"] == group_id: @@ -154,21 +137,17 @@ async def delete_project_group( user_id: UserID, project_id: ProjectID, group_id: GroupID, - product_name: ProductName, # pylint: disable=unused-argument + product_name: ProductName, ) -> None: - project_db: ProjectDBAPI = app[APP_PROJECT_DBAPI] - - project_access_rights: UserProjectAccessRights = ( - await project_db.get_project_access_rights_for_user( - user_id=user_id, project_uuid=project_id # add product_name - ) + await check_user_project_permission( + app, + project_id=project_id, + user_id=user_id, + product_name=product_name, + permission="delete", ) - if project_access_rights.delete is False: - raise ProjectInvalidRightsError( - user_id=user_id, - project_uuid=project_id, - reason=f"User does not have delete access to project {project_id}", - ) + + project_db: ProjectDBAPI = app[APP_PROJECT_DBAPI] project = await project_db.get_project_db(project_id) project_owner_user: dict = await users_api.get_user(app, project.prj_owner) if project_owner_user["primary_gid"] == group_id: diff --git a/services/web/server/src/simcore_service_webserver/projects/_nodes_handlers.py b/services/web/server/src/simcore_service_webserver/projects/_nodes_handlers.py index b2ac322b031..58b06af19e4 100644 --- a/services/web/server/src/simcore_service_webserver/projects/_nodes_handlers.py +++ b/services/web/server/src/simcore_service_webserver/projects/_nodes_handlers.py @@ -66,6 +66,7 @@ from ..groups.api import get_group_from_gid, list_all_user_groups from ..groups.exceptions import GroupNotFoundError from ..login.decorators import login_required +from ..projects.api import has_user_project_access_rights from ..security.decorators import permission_required from ..users.api import get_user_id_from_gid, get_user_role from ..users.exceptions import UserDefaultWalletNotFoundError @@ -74,7 +75,6 @@ from . import nodes_utils, projects_api from ._common_models import ProjectPathParams, RequestContext from ._nodes_api import NodeScreenshot, get_node_screenshots -from .db import ProjectDBAPI from .exceptions import ( ClustersKeeperNotAvailableError, DefaultPricingUnitNotFoundError, @@ -257,6 +257,7 @@ async def delete_node(request: web.Request) -> web.Response: path_params.project_id, req_ctx.user_id, NodeIDStr(path_params.node_id), + req_ctx.product_name, ) raise web.HTTPNoContent(content_type=MIMETYPE_APPLICATION_JSON) @@ -365,9 +366,10 @@ async def stop_node(request: web.Request) -> web.Response: req_ctx = RequestContext.parse_obj(request) path_params = parse_request_path_parameters_as(NodePathParams, request) - save_state = await ProjectDBAPI.get_from_app_context(request.app).has_permission( + save_state = await has_user_project_access_rights( + request.app, + project_id=path_params.project_id, user_id=req_ctx.user_id, - project_uuid=f"{path_params.project_id}", permission="write", ) diff --git a/services/web/server/src/simcore_service_webserver/projects/_ports_handlers.py b/services/web/server/src/simcore_service_webserver/projects/_ports_handlers.py index e014014cd59..0d2fb6f3eca 100644 --- a/services/web/server/src/simcore_service_webserver/projects/_ports_handlers.py +++ b/services/web/server/src/simcore_service_webserver/projects/_ports_handlers.py @@ -30,6 +30,7 @@ from .._meta import API_VTAG as VTAG from ..login.decorators import login_required +from ..projects._access_rights_api import check_user_project_permission from ..security.decorators import permission_required from . import _ports_api, projects_api from ._common_models import ProjectPathParams, RequestContext @@ -152,6 +153,14 @@ async def update_project_inputs(request: web.Request) -> web.Response: ) # patch workbench + await check_user_project_permission( + request.app, + project_id=path_params.project_id, + user_id=req_ctx.user_id, + product_name=req_ctx.product_name, + permission="write", + ) + assert db # nosec updated_project, _ = await db.update_project_multiple_node_data( user_id=req_ctx.user_id, diff --git a/services/web/server/src/simcore_service_webserver/projects/_projects_access.py b/services/web/server/src/simcore_service_webserver/projects/_projects_access.py index 685fc0c00ac..d16dceb9866 100644 --- a/services/web/server/src/simcore_service_webserver/projects/_projects_access.py +++ b/services/web/server/src/simcore_service_webserver/projects/_projects_access.py @@ -2,6 +2,7 @@ from aiohttp import web from simcore_postgres_database.models.users import UserRole +from ..projects.api import check_user_project_permission from ..security.api import get_access_model from .db import ProjectDBAPI @@ -12,6 +13,7 @@ async def can_update_node_inputs(context): Returns True if user has permission to update inputs """ db: ProjectDBAPI = context["dbapi"] + app: web.Application = context["app"] project_uuid = context["project_id"] user_id = context["user_id"] updated_project = context["new_data"] @@ -19,8 +21,16 @@ async def can_update_node_inputs(context): if project_uuid is None or user_id is None: return False + product_name = await db.get_project_product(project_uuid) + await check_user_project_permission( + app, + project_id=project_uuid, + user_id=user_id, + product_name=product_name, + permission="read", + ) # get current version - current_project, _ = await db.get_project(user_id, project_uuid) + current_project, _ = await db.get_project(project_uuid) diffs = jsondiff.diff(current_project, updated_project) diff --git a/services/web/server/src/simcore_service_webserver/projects/_states_handlers.py b/services/web/server/src/simcore_service_webserver/projects/_states_handlers.py index a687aba4513..cc0e5b7ef57 100644 --- a/services/web/server/src/simcore_service_webserver/projects/_states_handlers.py +++ b/services/web/server/src/simcore_service_webserver/projects/_states_handlers.py @@ -113,7 +113,7 @@ async def open_project(request: web.Request) -> web.Response: user_id=req_ctx.user_id, include_state=True, check_permissions=( - "read|write" if project_type is ProjectType.TEMPLATE else "read" + "write" if project_type is ProjectType.TEMPLATE else "read" ), ) diff --git a/services/web/server/src/simcore_service_webserver/projects/_tags_api.py b/services/web/server/src/simcore_service_webserver/projects/_tags_api.py new file mode 100644 index 00000000000..60f08dd1387 --- /dev/null +++ b/services/web/server/src/simcore_service_webserver/projects/_tags_api.py @@ -0,0 +1,55 @@ +""" Handlers for CRUD operations on /projects/{*}/tags/{*} + +""" + +import logging + +from aiohttp import web +from models_library.projects import ProjectID +from models_library.users import UserID + +from ._access_rights_api import check_user_project_permission +from .db import ProjectDBAPI +from .models import ProjectDict + +_logger = logging.getLogger(__name__) + + +async def add_tag( + app: web.Application, user_id: UserID, project_uuid: ProjectID, tag_id: int +) -> ProjectDict: + db: ProjectDBAPI = ProjectDBAPI.get_from_app_context(app) + + product_name = await db.get_project_product(project_uuid) + await check_user_project_permission( + app, + project_id=project_uuid, + user_id=user_id, + product_name=product_name, + permission="write", # NOTE: before there was only read access necessary + ) + + project: ProjectDict = await db.add_tag( + project_uuid=f"{project_uuid}", user_id=user_id, tag_id=int(tag_id) + ) + return project + + +async def remove_tag( + app: web.Application, user_id: UserID, project_uuid: ProjectID, tag_id: int +) -> ProjectDict: + db: ProjectDBAPI = ProjectDBAPI.get_from_app_context(app) + + product_name = await db.get_project_product(project_uuid) + await check_user_project_permission( + app, + project_id=project_uuid, + user_id=user_id, + product_name=product_name, + permission="write", # NOTE: before there was only read access necessary + ) + + project: ProjectDict = await db.remove_tag( + project_uuid=f"{project_uuid}", user_id=user_id, tag_id=tag_id + ) + return project diff --git a/services/web/server/src/simcore_service_webserver/projects/_tags_handlers.py b/services/web/server/src/simcore_service_webserver/projects/_tags_handlers.py index 8a53e40da46..4904dac4ce2 100644 --- a/services/web/server/src/simcore_service_webserver/projects/_tags_handlers.py +++ b/services/web/server/src/simcore_service_webserver/projects/_tags_handlers.py @@ -5,14 +5,14 @@ import logging from aiohttp import web +from models_library.projects import ProjectID from servicelib.request_keys import RQT_USERID_KEY from simcore_service_webserver.utils_aiohttp import envelope_json_response from .._meta import API_VTAG from ..login.decorators import login_required from ..security.decorators import permission_required -from .db import APP_PROJECT_DBAPI, ProjectDBAPI -from .models import ProjectDict +from . import _tags_api as tags_api _logger = logging.getLogger(__name__) @@ -25,7 +25,6 @@ @permission_required("project.tag.*") async def add_tag(request: web.Request): user_id: int = request[RQT_USERID_KEY] - db: ProjectDBAPI = request.config_dict[APP_PROJECT_DBAPI] try: tag_id, project_uuid = ( @@ -35,8 +34,11 @@ async def add_tag(request: web.Request): except KeyError as err: raise web.HTTPBadRequest(reason=f"Invalid request parameter {err}") from err - project: ProjectDict = await db.add_tag( - project_uuid=project_uuid, user_id=user_id, tag_id=int(tag_id) + project = await tags_api.add_tag( + request.app, + user_id=user_id, + project_uuid=ProjectID(project_uuid), + tag_id=int(tag_id), ) return envelope_json_response(project) @@ -48,13 +50,16 @@ async def add_tag(request: web.Request): @permission_required("project.tag.*") async def remove_tag(request: web.Request): user_id: int = request[RQT_USERID_KEY] - db: ProjectDBAPI = request.config_dict[APP_PROJECT_DBAPI] tag_id, project_uuid = ( request.match_info["tag_id"], request.match_info["project_uuid"], ) - project: ProjectDict = await db.remove_tag( - project_uuid=project_uuid, user_id=user_id, tag_id=int(tag_id) + project = await tags_api.remove_tag( + request.app, + user_id=user_id, + project_uuid=ProjectID(project_uuid), + tag_id=int(tag_id), ) + return envelope_json_response(project) diff --git a/services/web/server/src/simcore_service_webserver/projects/api.py b/services/web/server/src/simcore_service_webserver/projects/api.py index bf05c3335f3..6ec5370bb74 100644 --- a/services/web/server/src/simcore_service_webserver/projects/api.py +++ b/services/web/server/src/simcore_service_webserver/projects/api.py @@ -1,6 +1,10 @@ # NOTE: we will slowly move heere projects_api.py +from ._access_rights_api import ( + check_user_project_permission, + has_user_project_access_rights, +) from ._groups_api import ( create_project_group_without_checking_permissions, delete_project_group_without_checking_permissions, @@ -16,6 +20,8 @@ "connect_wallet_to_project", "delete_project_group_without_checking_permissions", "create_project_group_without_checking_permissions", + "check_user_project_permission", + "has_user_project_access_rights", ) diff --git a/services/web/server/src/simcore_service_webserver/projects/db.py b/services/web/server/src/simcore_service_webserver/projects/db.py index 4df9be4c3fe..ca97117b247 100644 --- a/services/web/server/src/simcore_service_webserver/projects/db.py +++ b/services/web/server/src/simcore_service_webserver/projects/db.py @@ -17,6 +17,7 @@ from aiopg.sa.result import ResultProxy, RowProxy from models_library.basic_types import IDStr from models_library.folders import FolderID +from models_library.products import ProductName from models_library.projects import ProjectID, ProjectIDStr from models_library.projects_comments import CommentID, ProjectsCommentsDB from models_library.projects_nodes import Node @@ -30,15 +31,16 @@ from models_library.users import UserID from models_library.utils.fastapi_encoders import jsonable_encoder from models_library.wallets import WalletDB, WalletID +from models_library.workspaces import WorkspaceID from pydantic import parse_obj_as from pydantic.types import PositiveInt from servicelib.aiohttp.application_keys import APP_DB_ENGINE_KEY from servicelib.logging_utils import get_log_record_extra, log_context from simcore_postgres_database.errors import UniqueViolation -from simcore_postgres_database.models.folders import folders_to_projects from simcore_postgres_database.models.groups import user_to_groups from simcore_postgres_database.models.project_to_groups import project_to_groups from simcore_postgres_database.models.projects_nodes import projects_nodes +from simcore_postgres_database.models.projects_to_folders import projects_to_folders from simcore_postgres_database.models.projects_to_products import projects_to_products from simcore_postgres_database.models.wallets import wallets from simcore_postgres_database.utils_groups_extra_properties import ( @@ -58,7 +60,6 @@ from tenacity.asyncio import AsyncRetrying from tenacity.retry import retry_if_exception_type -from ..application_settings import ApplicationSettings from ..db.models import projects_tags, projects_to_wallet from ..utils import now_str from ._comments_db import ( @@ -72,10 +73,8 @@ from ._db_utils import ( ANY_USER_ID_SENTINEL, BaseProjectDB, - PermissionStr, ProjectAccessRights, assemble_array_groups, - check_project_permissions, convert_to_db_names, convert_to_schema_names, create_project_access_rights, @@ -280,6 +279,8 @@ async def insert_project( insert_values.setdefault("name", "New Study") insert_values.setdefault("workbench", {}) + insert_values.setdefault("workspace_id", None) + # must be valid uuid try: ProjectID(str(insert_values.get("uuid"))) @@ -321,10 +322,9 @@ async def upsert_project_linked_product( async def list_projects( # pylint: disable=too-many-arguments self, - user_id: PositiveInt, *, product_name: str, - settings: ApplicationSettings, + user_id: PositiveInt, filter_by_project_type: ProjectType | None = None, filter_by_services: list[dict] | None = None, only_published: bool | None = False, @@ -336,14 +336,23 @@ async def list_projects( # pylint: disable=too-many-arguments field=IDStr("last_change_date"), direction=OrderDirection.DESC ), folder_id: FolderID | None = None, + workspace_id: WorkspaceID | None, ) -> tuple[list[dict[str, Any]], list[ProjectType], int]: + """ + If workspace_id is provided, then listing in workspace is considered/preffered + """ assert ( order_by.field in projects.columns ), "Guaranteed by ProjectListWithJsonStrParams" # nosec + # helper + private_workspace_user_id_or_none: UserID | None = ( + None if workspace_id else user_id + ) + async with self.engine.acquire() as conn: - user_groups: list[RowProxy] = await self._list_user_groups(conn, user_id) + # if workspace_is_private: access_rights_subquery = ( sa.select( project_to_groups.c.project_uuid, @@ -357,19 +366,60 @@ async def list_projects( # pylint: disable=too-many-arguments "delete", project_to_groups.c.delete, ), - ).label("access_rights"), + ) + .filter( + project_to_groups.c.read # Filters out entries where "read" is False + ) + .label("access_rights"), ).group_by(project_to_groups.c.project_uuid) ).subquery("access_rights_subquery") - - _join_query = projects.join(projects_to_products, isouter=True).join( - access_rights_subquery, isouter=True + # else: + # access_rights_subquery = ( + # sa.select( + # workspaces_access_rights.c.workspace_id, + # sa.func.jsonb_object_agg( + # workspaces_access_rights.c.gid, + # sa.func.jsonb_build_object( + # "read", + # workspaces_access_rights.c.read, + # "write", + # workspaces_access_rights.c.write, + # "delete", + # workspaces_access_rights.c.delete, + # ), + # ) + # .filter( + # workspaces_access_rights.c.read # Filters out entries where "read" is False + # ) + # .label("access_rights"), + # ) + # .where(workspaces_access_rights.c.workspace_id == workspace_id) + # .group_by(workspaces_access_rights.c.workspace_id) + # ).subquery("access_rights_subquery") + + _join_query = ( + projects.join(projects_to_products, isouter=True) + .join(access_rights_subquery, isouter=True) + .join( + projects_to_folders, + ( + (projects_to_folders.c.project_uuid == projects.c.uuid) + & ( + projects_to_folders.c.user_id + == private_workspace_user_id_or_none + ) + ), + isouter=True, + ) ) - if settings.WEBSERVER_FOLDERS: - _join_query = _join_query.join(folders_to_projects, isouter=True) query = ( sa.select( - *[col for col in projects.columns if col.name != "access_rights"], + *[ + col + for col in projects.columns + if col.name not in ["access_rights"] + ], access_rights_subquery.c.access_rights, projects_to_products.c.product_name, ) @@ -390,28 +440,40 @@ async def list_projects( # pylint: disable=too-many-arguments if not include_hidden else sa.text("") ) - & ( - (projects.c.prj_owner == user_id) - | sa.text( - f"jsonb_exists_any(access_rights_subquery.access_rights, {assemble_array_groups(user_groups)})" - ) - ) & ( (projects_to_products.c.product_name == product_name) # This was added for backward compatibility, including old projects not in the projects_to_products table. | (projects_to_products.c.product_name.is_(None)) ) + & ( + projects_to_folders.c.folder_id == folder_id + if folder_id + else projects_to_folders.c.folder_id.is_(None) + ) + & ( + projects.c.workspace_id == workspace_id # <-- Shared workspace + if workspace_id + else projects.c.workspace_id.is_(None) # <-- Private workspace + ) ) ) - if settings.WEBSERVER_FOLDERS: + + if private_workspace_user_id_or_none: + # If Private workspace we check to which projects user has access + user_groups: list[RowProxy] = await self._list_user_groups( + conn, user_id + ) query = query.where( - folders_to_projects.c.folder_id == f"{folder_id}" - if folder_id - else folders_to_projects.c.folder_id.is_(None) + (projects.c.prj_owner == user_id) + | sa.text( + f"jsonb_exists_any(access_rights_subquery.access_rights, {assemble_array_groups(user_groups)})" + ) ) if search: - query = query.join(users, isouter=True) + query = query.join( + users, users.c.id == projects.c.prj_owner, isouter=True + ) query = query.where( (projects.c.name.ilike(f"%{search}%")) | (projects.c.description.ilike(f"%{search}%")) @@ -429,11 +491,10 @@ async def list_projects( # pylint: disable=too-many-arguments ) assert total_number_of_projects is not None # nosec - prjs, prj_types = await self._execute_with_permission_check( + prjs, prj_types = await self._execute_without_permission_check( conn, - select_projects_query=query.offset(offset).limit(limit), user_id=user_id, - user_groups=user_groups, + select_projects_query=query.offset(offset).limit(limit), filter_by_services=filter_by_services, ) @@ -454,12 +515,10 @@ async def list_projects_uuids(self, user_id: int) -> list[str]: async def get_project( self, - user_id: UserID, project_uuid: str, *, only_published: bool = False, only_templates: bool = False, - check_permissions: PermissionStr = "read", ) -> tuple[ProjectDict, ProjectType]: """Returns all projects *owned* by the user @@ -468,16 +527,13 @@ async def get_project( - Notice that a user can have access to a project where he/she has read access :raises ProjectNotFoundError: project is not assigned to user - raises ProjectInvalidRightsError: if user has no access rights to do check_permissions """ async with self.engine.acquire() as conn: project = await self._get_project( conn, - user_id, project_uuid, only_published=only_published, only_templates=only_templates, - check_permissions=check_permissions, ) # pylint: disable=no-value-for-parameter user_email = await self._get_user_email(conn, project["prj_owner"]) @@ -500,13 +556,13 @@ async def get_project( projects.c.prj_owner, projects.c.creation_date, projects.c.last_change_date, - projects.c.access_rights, projects.c.ui, projects.c.classifiers, projects.c.dev, projects.c.quality, projects.c.published, projects.c.hidden, + projects.c.workspace_id, ] async def get_project_db(self, project_uuid: ProjectID) -> ProjectDB: @@ -521,10 +577,13 @@ async def get_project_db(self, project_uuid: ProjectID) -> ProjectDB: raise ProjectNotFoundError(project_uuid=project_uuid) return ProjectDB.from_orm(row) - async def get_project_access_rights_for_user( + async def get_pure_project_access_rights_without_workspace( self, user_id: UserID, project_uuid: ProjectID ) -> UserProjectAccessRights: """ + Be careful what you want. You should use `get_user_project_access_rights` to get access rights on the + project. It depends on which context you are in, whether private or shared workspace. + User project access rights. Aggregated across all his groups. """ _SELECTION_ARGS = ( @@ -598,13 +657,11 @@ async def replace_project( current_project: dict = await self._get_project( db_connection, - user_id, project_uuid, exclude_foreign=["tags"], for_update=True, ) - user_groups = await self._list_user_groups(db_connection, user_id) - check_project_permissions(current_project, user_id, user_groups, "write") + # uuid can ONLY be set upon creation if current_project["uuid"] != new_project_data["uuid"]: raise ProjectInvalidRightsError( @@ -661,6 +718,18 @@ async def patch_project( raise ProjectNotFoundError(project_uuid=project_uuid) return ProjectDB.from_orm(row) + async def get_project_product(self, project_uuid: ProjectID) -> ProductName: + async with self.engine.acquire() as conn: + result = await conn.execute( + sa.select(projects_to_products.c.product_name).where( + projects.c.uuid == f"{project_uuid}" + ) + ) + row = await result.fetchone() + if row is None: + raise ProjectNotFoundError(project_uuid=project_uuid) + return cast(str, row[0]) + async def update_project_owner_without_checking_permissions( self, project_uuid: ProjectIDStr, @@ -703,12 +772,6 @@ async def delete_project(self, user_id: int, project_uuid: str): ) async with self.engine.acquire() as conn, conn.begin(): - project = await self._get_project( - conn, user_id, project_uuid, for_update=True - ) - # if we have delete access we delete the project - user_groups: list[RowProxy] = await self._list_user_groups(conn, user_id) - check_project_permissions(project, user_id, user_groups, "delete") await conn.execute( # pylint: disable=no-value-for-parameter projects.delete().where(projects.c.uuid == project_uuid) @@ -803,15 +866,10 @@ async def _update_project_workbench( current_project: dict = await self._get_project( db_connection, - user_id, project_uuid, exclude_foreign=["tags"], for_update=True, ) - user_groups: list[RowProxy] = await self._list_user_groups( - db_connection, user_id - ) - check_project_permissions(current_project, user_id, user_groups, "write") new_project_data, changed_entries = patch_workbench( current_project, @@ -981,41 +1039,6 @@ async def connect_pricing_unit_to_project_node( pricing_unit_id=pricing_unit_id, ) - # - # Project ACCESS RIGHTS/PERMISSIONS - # - - async def has_permission( - self, user_id: UserID, project_uuid: str, permission: PermissionStr - ) -> bool: - """ - NOTE: this function should never raise - NOTE: if user_id does not exist it is not an issue - """ - - async with self.engine.acquire() as conn: - try: - project = await self._get_project(conn, user_id, project_uuid) - user_groups: list[RowProxy] = await self._list_user_groups( - conn, user_id - ) - check_project_permissions(project, user_id, user_groups, permission) - return True - except (ProjectInvalidRightsError, ProjectNotFoundError): - return False - - async def check_delete_project_permission(self, user_id: int, project_uuid: str): - """ - raises ProjectInvalidRightsError - """ - async with self.engine.acquire() as conn, conn.begin(): - project = await self._get_project( - conn, user_id, project_uuid, for_update=True - ) - # if we have delete access we delete the project - user_groups: list[RowProxy] = await self._list_user_groups(conn, user_id) - check_project_permissions(project, user_id, user_groups, "delete") - # # Project TAGS # @@ -1026,7 +1049,7 @@ async def add_tag( """Creates a tag and associates it to this project""" async with self.engine.acquire() as conn: project = await self._get_project( - conn, user_id=user_id, project_uuid=project_uuid, exclude_foreign=None + conn, project_uuid=project_uuid, exclude_foreign=None ) user_email = await self._get_user_email(conn, user_id) @@ -1048,7 +1071,7 @@ async def remove_tag( self, user_id: int, project_uuid: str, tag_id: int ) -> ProjectDict: async with self.engine.acquire() as conn: - project = await self._get_project(conn, user_id, project_uuid) + project = await self._get_project(conn, project_uuid) user_email = await self._get_user_email(conn, user_id) # pylint: disable=no-value-for-parameter query = projects_tags.delete().where( diff --git a/services/web/server/src/simcore_service_webserver/projects/models.py b/services/web/server/src/simcore_service_webserver/projects/models.py index eb6bfad13c9..c124c9547fc 100644 --- a/services/web/server/src/simcore_service_webserver/projects/models.py +++ b/services/web/server/src/simcore_service_webserver/projects/models.py @@ -5,13 +5,13 @@ from aiopg.sa.result import RowProxy from models_library.basic_types import HttpUrlWithCustomMinLength from models_library.projects import ClassifierID, ProjectID -from models_library.projects_access import AccessRights, GroupIDStr from models_library.projects_ui import StudyUI from models_library.users import UserID from models_library.utils.common_validators import ( empty_str_to_none_pre_validator, none_to_empty_str_pre_validator, ) +from models_library.workspaces import WorkspaceID from pydantic import BaseModel, validator from simcore_postgres_database.models.projects import ProjectType, projects @@ -43,13 +43,13 @@ class ProjectDB(BaseModel): prj_owner: UserID creation_date: datetime last_change_date: datetime - access_rights: dict[GroupIDStr, AccessRights] ui: StudyUI | None classifiers: list[ClassifierID] dev: dict | None quality: dict[str, Any] published: bool hidden: bool + workspace_id: WorkspaceID | None class Config: orm_mode = True @@ -64,7 +64,7 @@ class Config: assert set(ProjectDB.__fields__.keys()).issubset( # nosec - {c.name for c in projects.columns} + {c.name for c in projects.columns if c.name not in ["access_rights"]} ) diff --git a/services/web/server/src/simcore_service_webserver/projects/projects_api.py b/services/web/server/src/simcore_service_webserver/projects/projects_api.py index 8d3c0783111..18cac2b32f9 100644 --- a/services/web/server/src/simcore_service_webserver/projects/projects_api.py +++ b/services/web/server/src/simcore_service_webserver/projects/projects_api.py @@ -18,7 +18,7 @@ from contextlib import suppress from decimal import Decimal from pprint import pformat -from typing import Any, Final +from typing import Any, Final, cast from uuid import UUID, uuid4 from aiohttp import web @@ -86,6 +86,7 @@ ProjectNodesNodeNotFoundError, ) from simcore_postgres_database.webserver_models import ProjectType +from simcore_service_webserver.projects._db_utils import PermissionStr from ..application_settings import get_application_settings from ..catalog import client as catalog_client @@ -118,6 +119,10 @@ from ..wallets import api as wallets_api from ..wallets.errors import WalletNotEnoughCreditsError from . import _crud_api_delete, _nodes_api +from ._access_rights_api import ( + check_user_project_permission, + has_user_project_access_rights, +) from ._nodes_utils import set_reservation_same_as_limit, validate_new_service_resources from ._wallets_api import connect_wallet_to_project, get_project_wallet from .db import APP_PROJECT_DBAPI, ProjectDBAPI @@ -172,10 +177,17 @@ async def get_project_for_user( """ db = ProjectDBAPI.get_from_app_context(app) + product_name = await db.get_project_product(ProjectID(project_uuid)) + await check_user_project_permission( + app, + project_id=ProjectID(project_uuid), + user_id=user_id, + product_name=product_name, + permission=cast(PermissionStr, check_permissions), + ) + project, project_type = await db.get_project( - user_id, project_uuid, - check_permissions=check_permissions, # type: ignore[arg-type] ) # adds state if it is not a template @@ -184,6 +196,8 @@ async def get_project_for_user( user_id, project, project_type is ProjectType.TEMPLATE, app ) + # If from workspace -> hack workspace permissions + Project.parse_obj(project) # NOTE: only validates return project @@ -226,11 +240,13 @@ async def patch_project( project_db = await db.get_project_db(project_uuid=project_uuid) # 2. Check user permissions - _user_project_access_rights = await db.get_project_access_rights_for_user( - user_id, project_uuid + _user_project_access_rights = await check_user_project_permission( + app, + project_id=project_uuid, + user_id=user_id, + product_name=product_name, + permission="write", ) - if not _user_project_access_rights.write: - raise ProjectInvalidRightsError(user_id=user_id, project_uuid=project_uuid) # 3. If patching access rights if new_prj_access_rights := _project_patch_exclude_unset.get("access_rights"): @@ -460,10 +476,23 @@ def _by_type_name(ec2: EC2InstanceTypeGet) -> bool: async def _check_project_node_has_all_required_inputs( - db: ProjectDBAPI, user_id: UserID, project_uuid: ProjectID, node_id: NodeID + app: web.Application, + db: ProjectDBAPI, + user_id: UserID, + project_uuid: ProjectID, + node_id: NodeID, ) -> None: - project_dict, _ = await db.get_project(user_id, f"{project_uuid}") + product_name = await db.get_project_product(project_uuid) + await check_user_project_permission( + app, + project_id=project_uuid, + user_id=user_id, + product_name=product_name, + permission="read", + ) + + project_dict, _ = await db.get_project(f"{project_uuid}") nodes_map: dict[NodeID, Node] = { NodeID(k): Node(**v) for k, v in project_dict["workbench"].items() @@ -530,7 +559,7 @@ async def _start_dynamic_service( try: await _check_project_node_has_all_required_inputs( - db, user_id, project_uuid, node_uuid + request.app, db, user_id, project_uuid, node_uuid ) except ProjectNodeRequiredInputsNotSetError as e: if graceful_start: @@ -545,10 +574,8 @@ async def _start_dynamic_service( save_state = False user_role: UserRole = await get_user_role(request.app, user_id) if user_role > UserRole.GUEST: - save_state = await ProjectDBAPI.get_from_app_context( - request.app - ).has_permission( - user_id=user_id, project_uuid=f"{project_uuid}", permission="write" + save_state = await has_user_project_access_rights( + request.app, project_id=project_uuid, user_id=user_id, permission="write" ) lock_key = _nodes_api.get_service_start_lock_key(user_id, project_uuid) @@ -729,6 +756,15 @@ async def add_project_node( user_id, extra=get_log_record_extra(user_id=user_id), ) + + await check_user_project_permission( + request.app, + project_id=project["uuid"], + user_id=user_id, + product_name=product_name, + permission="write", + ) + node_uuid = NodeID(service_id if service_id else f"{uuid4()}") default_resources = await catalog_client.get_service_resources( request.app, user_id, service_key, service_version @@ -829,12 +865,24 @@ async def _remove_service_and_its_data_folders( async def delete_project_node( - request: web.Request, project_uuid: ProjectID, user_id: UserID, node_uuid: NodeIDStr + request: web.Request, + project_uuid: ProjectID, + user_id: UserID, + node_uuid: NodeIDStr, + product_name: ProductName, ) -> None: log.debug( "deleting node %s in project %s for user %s", node_uuid, project_uuid, user_id ) + await check_user_project_permission( + request.app, + project_id=project_uuid, + user_id=user_id, + product_name=product_name, + permission="write", + ) + list_running_dynamic_services = await director_v2_api.list_dynamic_services( request.app, project_id=f"{project_uuid}", user_id=user_id ) @@ -893,6 +941,15 @@ async def update_project_node_state( ) db: ProjectDBAPI = app[APP_PROJECT_DBAPI] + product_name = await db.get_project_product(project_id) + await check_user_project_permission( + app, + project_id=project_id, + user_id=user_id, + product_name=product_name, + permission="write", # NOTE: MD: before only read was sufficient, double check this + ) + updated_project, _ = await db.update_project_node_data( user_id=user_id, project_uuid=project_id, @@ -925,17 +982,17 @@ async def patch_project_node( db: ProjectDBAPI = app[APP_PROJECT_DBAPI] # 1. Check user permissions - _user_project_access_rights = await db.get_project_access_rights_for_user( - user_id, project_id + await check_user_project_permission( + app, + project_id=project_id, + user_id=user_id, + product_name=product_name, + permission="write", # NOTE: MD: before only read was sufficient, double check this ) - if not _user_project_access_rights.write: - raise ProjectInvalidRightsError(user_id=user_id, project_uuid=project_id) # 2. If patching service key or version make sure it's valid if _node_patch_exclude_unset.get("key") or _node_patch_exclude_unset.get("version"): - _project, _ = await db.get_project( - user_id=user_id, project_uuid=f"{project_id}" - ) + _project, _ = await db.get_project(project_uuid=f"{project_id}") _project_node_data = _project["workbench"][f"{node_id}"] _service_key = _node_patch_exclude_unset.get("key", _project_node_data["key"]) @@ -996,6 +1053,15 @@ async def update_project_node_outputs( new_outputs = new_outputs or {} db: ProjectDBAPI = app[APP_PROJECT_DBAPI] + product_name = await db.get_project_product(project_id) + await check_user_project_permission( + app, + project_id=project_id, + user_id=user_id, + product_name=product_name, + permission="write", # NOTE: MD: before only read was sufficient, double check this + ) + updated_project, changed_entries = await db.update_project_node_data( user_id=user_id, project_uuid=project_id, @@ -1625,8 +1691,8 @@ async def remove_project_dynamic_services( except UserNotFoundError: user_role = None - save_state = await ProjectDBAPI.get_from_app_context(app).has_permission( - user_id=user_id, project_uuid=project_uuid, permission="write" + save_state = await has_user_project_access_rights( + app, project_id=ProjectID(project_uuid), user_id=user_id, permission="write" ) if user_role is None or user_role <= UserRole.GUEST: save_state = False 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 f237d8b48da..5e38b3dcff4 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 @@ -82,6 +82,7 @@ class PermissionDict(TypedDict, total=False): "user.profile.update", "user.tokens.*", "wallets.*", + "workspaces.*", ], inherits=[UserRole.GUEST, UserRole.ANONYMOUS], ), diff --git a/services/web/server/src/simcore_service_webserver/studies_dispatcher/_studies_access.py b/services/web/server/src/simcore_service_webserver/studies_dispatcher/_studies_access.py index 65f079f7420..9545675d7ec 100644 --- a/services/web/server/src/simcore_service_webserver/studies_dispatcher/_studies_access.py +++ b/services/web/server/src/simcore_service_webserver/studies_dispatcher/_studies_access.py @@ -31,8 +31,14 @@ update_dynamic_service_networks_in_project, ) from ..products.api import get_current_product, get_product_name -from ..projects.db import ANY_USER, ProjectDBAPI -from ..projects.exceptions import ProjectInvalidRightsError, ProjectNotFoundError +from ..projects._groups_db import get_project_group +from ..projects.api import check_user_project_permission +from ..projects.db import ProjectDBAPI +from ..projects.exceptions import ( + ProjectGroupNotFoundError, + ProjectInvalidRightsError, + ProjectNotFoundError, +) from ..projects.models import ProjectDict from ..security.api import is_anonymous, remember_identity from ..storage.api import copy_data_folders_from_project @@ -84,17 +90,27 @@ async def _get_published_template_project( only_templates=True, # 2. If user is unauthenticated, then MUST be public only_published=only_public_projects, - # 3. MUST be shared with EVERYONE=1 in read mode, i.e. - user_id=ANY_USER, # any user - check_permissions="read", # any user has read access ) + # 3. MUST be shared with EVERYONE=1 in read mode, i.e. + project_group_get = await get_project_group( + request.app, project_id=ProjectID(project_uuid), group_id=1 + ) + if project_group_get.read is False: + raise ProjectGroupNotFoundError( + reason=f"Project {project_uuid} group 1 not read access" + ) + if not prj: # Not sure this happens but this condition was checked before so better be safe raise ProjectNotFoundError(project_uuid) return prj - except (ProjectNotFoundError, ProjectInvalidRightsError) as err: + except ( + ProjectGroupNotFoundError, + ProjectNotFoundError, + ProjectInvalidRightsError, + ) as err: _logger.debug( "Project with %s %s was not found. Reason: %s", f"{project_uuid=}", @@ -138,8 +154,17 @@ async def copy_study_to_account( ) try: + product_name = await db.get_project_product(template_project["uuid"]) + await check_user_project_permission( + request.app, + project_id=template_project["uuid"], + user_id=user["id"], + product_name=product_name, + permission="read", + ) + # Avoids multiple copies of the same template on each account - await db.get_project(user["id"], project_uuid) + await db.get_project(project_uuid) except ProjectNotFoundError: # New project cloned from template diff --git a/services/web/server/src/simcore_service_webserver/folders/_groups_db.py b/services/web/server/src/simcore_service_webserver/workspaces/__init__.py similarity index 100% rename from services/web/server/src/simcore_service_webserver/folders/_groups_db.py rename to services/web/server/src/simcore_service_webserver/workspaces/__init__.py diff --git a/services/web/server/src/simcore_service_webserver/workspaces/_groups_api.py b/services/web/server/src/simcore_service_webserver/workspaces/_groups_api.py new file mode 100644 index 00000000000..d58fc8e2ab7 --- /dev/null +++ b/services/web/server/src/simcore_service_webserver/workspaces/_groups_api.py @@ -0,0 +1,179 @@ +import logging +from datetime import datetime + +from aiohttp import web +from models_library.products import ProductName +from models_library.users import GroupID, UserID +from models_library.workspaces import UserWorkspaceAccessRightsDB, WorkspaceID +from pydantic import BaseModel, parse_obj_as + +from ..users import api as users_api +from . import _groups_db as workspaces_groups_db +from . import _workspaces_db as workspaces_db +from ._groups_db import WorkspaceGroupGetDB +from ._workspaces_api import check_user_workspace_access +from .errors import WorkspaceAccessForbiddenError + +log = logging.getLogger(__name__) + + +class WorkspaceGroupGet(BaseModel): + gid: GroupID + read: bool + write: bool + delete: bool + created: datetime + modified: datetime + + +async def create_workspace_group( + app: web.Application, + *, + user_id: UserID, + workspace_id: WorkspaceID, + group_id: GroupID, + read: bool, + write: bool, + delete: bool, + product_name: ProductName, +) -> WorkspaceGroupGet: + await check_user_workspace_access( + app=app, + user_id=user_id, + workspace_id=workspace_id, + product_name=product_name, + permission="write", + ) + + workspace_group_db: WorkspaceGroupGetDB = ( + await workspaces_groups_db.create_workspace_group( + app=app, + workspace_id=workspace_id, + group_id=group_id, + read=read, + write=write, + delete=delete, + ) + ) + workspace_group_api: WorkspaceGroupGet = WorkspaceGroupGet( + **workspace_group_db.dict() + ) + + return workspace_group_api + + +async def list_workspace_groups_by_user_and_workspace( + app: web.Application, + *, + user_id: UserID, + workspace_id: WorkspaceID, + product_name: ProductName, +) -> list[WorkspaceGroupGet]: + await check_user_workspace_access( + app=app, + user_id=user_id, + workspace_id=workspace_id, + product_name=product_name, + permission="read", + ) + + workspace_groups_db: list[ + WorkspaceGroupGetDB + ] = await workspaces_groups_db.list_workspace_groups( + app=app, workspace_id=workspace_id + ) + + workspace_groups_api: list[WorkspaceGroupGet] = [ + parse_obj_as(WorkspaceGroupGet, group) for group in workspace_groups_db + ] + + return workspace_groups_api + + +async def list_workspace_groups_with_read_access_by_workspace( + app: web.Application, + *, + workspace_id: WorkspaceID, +) -> list[WorkspaceGroupGet]: + workspace_groups_db: list[ + WorkspaceGroupGetDB + ] = await workspaces_groups_db.list_workspace_groups( + app=app, workspace_id=workspace_id + ) + + workspace_groups_api: list[WorkspaceGroupGet] = [ + parse_obj_as(WorkspaceGroupGet, group) + for group in workspace_groups_db + if group.read is True + ] + + return workspace_groups_api + + +async def update_workspace_group( + app: web.Application, + *, + user_id: UserID, + workspace_id: WorkspaceID, + group_id: GroupID, + read: bool, + write: bool, + delete: bool, + product_name: ProductName, +) -> WorkspaceGroupGet: + workspace: UserWorkspaceAccessRightsDB = await workspaces_db.get_workspace_for_user( + app=app, user_id=user_id, workspace_id=workspace_id, product_name=product_name + ) + if workspace.my_access_rights.write is False: + raise WorkspaceAccessForbiddenError( + reason=f"User does not have write access to workspace {workspace_id}" + ) + if workspace.owner_primary_gid == group_id: + user: dict = await users_api.get_user(app, user_id) + if user["primary_gid"] != workspace.owner_primary_gid: + # Only the owner of the workspace can modify the owner group + raise WorkspaceAccessForbiddenError( + reason=f"User does not have access to modify owner workspace group in workspace {workspace_id}" + ) + + workspace_group_db: WorkspaceGroupGetDB = ( + await workspaces_groups_db.update_workspace_group( + app=app, + workspace_id=workspace_id, + group_id=group_id, + read=read, + write=write, + delete=delete, + ) + ) + + workspace_api: WorkspaceGroupGet = WorkspaceGroupGet(**workspace_group_db.dict()) + return workspace_api + + +async def delete_workspace_group( + app: web.Application, + *, + user_id: UserID, + workspace_id: WorkspaceID, + group_id: GroupID, + product_name: ProductName, +) -> None: + workspace: UserWorkspaceAccessRightsDB = await workspaces_db.get_workspace_for_user( + app=app, user_id=user_id, workspace_id=workspace_id, product_name=product_name + ) + if workspace.my_access_rights.delete is False: + raise WorkspaceAccessForbiddenError( + reason=f"User does not have delete access to workspace {workspace_id}" + ) + if workspace.owner_primary_gid == group_id: + user: dict = await users_api.get_user(app, user_id) + if user["primary_gid"] != workspace.owner_primary_gid: + # Only the owner of the workspace can delete the owner group + raise WorkspaceAccessForbiddenError( + reason=f"User does not have access to modify owner workspace group in workspace {workspace_id}" + ) + + await workspaces_groups_db.delete_workspace_group( + app=app, workspace_id=workspace_id, group_id=group_id + ) diff --git a/services/web/server/src/simcore_service_webserver/workspaces/_groups_db.py b/services/web/server/src/simcore_service_webserver/workspaces/_groups_db.py new file mode 100644 index 00000000000..daeba51ae80 --- /dev/null +++ b/services/web/server/src/simcore_service_webserver/workspaces/_groups_db.py @@ -0,0 +1,165 @@ +""" Database API + + - Adds a layer to the postgres API with a focus on the projects comments + +""" +import logging +from datetime import datetime + +from aiohttp import web +from models_library.users import GroupID +from models_library.workspaces import WorkspaceID +from pydantic import BaseModel +from simcore_postgres_database.models.workspaces_access_rights import ( + workspaces_access_rights, +) +from sqlalchemy import func, literal_column +from sqlalchemy.sql import select + +from ..db.plugin import get_database_engine +from .errors import WorkspaceGroupNotFoundError + +_logger = logging.getLogger(__name__) + +### Models + + +class WorkspaceGroupGetDB(BaseModel): + gid: GroupID + read: bool + write: bool + delete: bool + created: datetime + modified: datetime + + class Config: + orm_mode = True + + +## DB API + + +async def create_workspace_group( + app: web.Application, + workspace_id: WorkspaceID, + group_id: GroupID, + *, + read: bool, + write: bool, + delete: bool, +) -> WorkspaceGroupGetDB: + async with get_database_engine(app).acquire() as conn: + result = await conn.execute( + workspaces_access_rights.insert() + .values( + workspace_id=workspace_id, + gid=group_id, + read=read, + write=write, + delete=delete, + created=func.now(), + modified=func.now(), + ) + .returning(literal_column("*")) + ) + row = await result.first() + return WorkspaceGroupGetDB.from_orm(row) + + +async def list_workspace_groups( + app: web.Application, + workspace_id: WorkspaceID, +) -> list[WorkspaceGroupGetDB]: + stmt = ( + select( + workspaces_access_rights.c.gid, + workspaces_access_rights.c.read, + workspaces_access_rights.c.write, + workspaces_access_rights.c.delete, + workspaces_access_rights.c.created, + workspaces_access_rights.c.modified, + ) + .select_from(workspaces_access_rights) + .where(workspaces_access_rights.c.workspace_id == workspace_id) + ) + + async with get_database_engine(app).acquire() as conn: + result = await conn.execute(stmt) + rows = await result.fetchall() or [] + return [WorkspaceGroupGetDB.from_orm(row) for row in rows] + + +async def get_workspace_group( + app: web.Application, + workspace_id: WorkspaceID, + group_id: GroupID, +) -> WorkspaceGroupGetDB: + stmt = ( + select( + workspaces_access_rights.c.gid, + workspaces_access_rights.c.read, + workspaces_access_rights.c.write, + workspaces_access_rights.c.delete, + workspaces_access_rights.c.created, + workspaces_access_rights.c.modified, + ) + .select_from(workspaces_access_rights) + .where( + (workspaces_access_rights.c.workspace_id == workspace_id) + & (workspaces_access_rights.c.gid == group_id) + ) + ) + + async with get_database_engine(app).acquire() as conn: + result = await conn.execute(stmt) + row = await result.first() + if row is None: + raise WorkspaceGroupNotFoundError( + workspace_id=workspace_id, group_id=group_id + ) + return WorkspaceGroupGetDB.from_orm(row) + + +async def update_workspace_group( + app: web.Application, + workspace_id: WorkspaceID, + group_id: GroupID, + *, + read: bool, + write: bool, + delete: bool, +) -> WorkspaceGroupGetDB: + async with get_database_engine(app).acquire() as conn: + result = await conn.execute( + workspaces_access_rights.update() + .values( + read=read, + write=write, + delete=delete, + ) + .where( + (workspaces_access_rights.c.workspace_id == workspace_id) + & (workspaces_access_rights.c.gid == group_id) + ) + .returning(literal_column("*")) + ) + row = await result.first() + if row is None: + raise WorkspaceGroupNotFoundError( + workspace_id=workspace_id, group_id=group_id + ) + return WorkspaceGroupGetDB.from_orm(row) + + +async def delete_workspace_group( + app: web.Application, + workspace_id: WorkspaceID, + group_id: GroupID, +) -> None: + async with get_database_engine(app).acquire() as conn: + await conn.execute( + workspaces_access_rights.delete().where( + (workspaces_access_rights.c.workspace_id == workspace_id) + & (workspaces_access_rights.c.gid == group_id) + ) + ) diff --git a/services/web/server/src/simcore_service_webserver/folders/_groups_handlers.py b/services/web/server/src/simcore_service_webserver/workspaces/_groups_handlers.py similarity index 50% rename from services/web/server/src/simcore_service_webserver/folders/_groups_handlers.py rename to services/web/server/src/simcore_service_webserver/workspaces/_groups_handlers.py index 0c2696f0722..0bf7d09eb68 100644 --- a/services/web/server/src/simcore_service_webserver/folders/_groups_handlers.py +++ b/services/web/server/src/simcore_service_webserver/workspaces/_groups_handlers.py @@ -6,8 +6,8 @@ import logging from aiohttp import web -from models_library.folders import FolderID from models_library.users import GroupID, UserID +from models_library.workspaces import WorkspaceID from pydantic import BaseModel, Extra, Field from servicelib.aiohttp.requests_validation import ( parse_request_body_as, @@ -23,9 +23,9 @@ from ..security.decorators import permission_required from ..utils_aiohttp import envelope_json_response from . import _groups_api -from ._folders_handlers import FoldersPathParams -from ._groups_api import FolderGroupGet -from .errors import FolderAccessForbiddenError, FolderGroupNotFoundError +from ._groups_api import WorkspaceGroupGet +from ._workspaces_handlers import WorkspacesPathParams +from .errors import WorkspaceAccessForbiddenError, WorkspaceGroupNotFoundError _logger = logging.getLogger(__name__) @@ -35,37 +35,37 @@ class _RequestContext(BaseModel): product_name: str = Field(..., alias=RQ_PRODUCT_KEY) # type: ignore[literal-required] -def _handle_folders_groups_exceptions(handler: Handler): +def _handle_workspaces_groups_exceptions(handler: Handler): @functools.wraps(handler) async def wrapper(request: web.Request) -> web.StreamResponse: try: return await handler(request) - except FolderGroupNotFoundError as exc: + except WorkspaceGroupNotFoundError as exc: raise web.HTTPNotFound(reason=f"{exc}") from exc - except FolderAccessForbiddenError as exc: + except WorkspaceAccessForbiddenError as exc: raise web.HTTPForbidden(reason=f"{exc}") from exc return wrapper # -# folders groups COLLECTION ------------------------- +# workspaces groups COLLECTION ------------------------- # routes = web.RouteTableDef() -class _FoldersGroupsPathParams(BaseModel): - folder_id: FolderID +class _WorkspacesGroupsPathParams(BaseModel): + workspace_id: WorkspaceID group_id: GroupID class Config: extra = Extra.forbid -class _FoldersGroupsBodyParams(BaseModel): +class _WorkspacesGroupsBodyParams(BaseModel): read: bool write: bool delete: bool @@ -75,20 +75,21 @@ class Config: @routes.post( - f"/{VTAG}/folders/{{folder_id}}/groups/{{group_id}}", name="create_folder_group" + f"/{VTAG}/workspaces/{{workspace_id}}/groups/{{group_id}}", + name="create_workspace_group", ) @login_required -@permission_required("folder.access_rights.update") -@_handle_folders_groups_exceptions -async def create_folder_group(request: web.Request): +@permission_required("workspaces.*") +@_handle_workspaces_groups_exceptions +async def create_workspace_group(request: web.Request): req_ctx = _RequestContext.parse_obj(request) - path_params = parse_request_path_parameters_as(_FoldersGroupsPathParams, request) - body_params = await parse_request_body_as(_FoldersGroupsBodyParams, request) + path_params = parse_request_path_parameters_as(_WorkspacesGroupsPathParams, request) + body_params = await parse_request_body_as(_WorkspacesGroupsBodyParams, request) - folder_groups: FolderGroupGet = await _groups_api.create_folder_group_by_user( + workspace_groups: WorkspaceGroupGet = await _groups_api.create_workspace_group( request.app, user_id=req_ctx.user_id, - folder_id=path_params.folder_id, + workspace_id=path_params.workspace_id, group_id=path_params.group_id, read=body_params.read, write=body_params.write, @@ -96,43 +97,45 @@ async def create_folder_group(request: web.Request): product_name=req_ctx.product_name, ) - return envelope_json_response(folder_groups, web.HTTPCreated) + return envelope_json_response(workspace_groups, web.HTTPCreated) -@routes.get(f"/{VTAG}/folders/{{folder_id}}/groups", name="list_folder_groups") +@routes.get(f"/{VTAG}/workspaces/{{workspace_id}}/groups", name="list_workspace_groups") @login_required -@permission_required("folder.read") -@_handle_folders_groups_exceptions -async def list_folder_groups(request: web.Request): +@permission_required("workspaces.*") +@_handle_workspaces_groups_exceptions +async def list_workspace_groups(request: web.Request): req_ctx = _RequestContext.parse_obj(request) - path_params = parse_request_path_parameters_as(FoldersPathParams, request) + path_params = parse_request_path_parameters_as(WorkspacesPathParams, request) - folders: list[FolderGroupGet] = await _groups_api.list_folder_groups_by_user( + workspaces: list[ + WorkspaceGroupGet + ] = await _groups_api.list_workspace_groups_by_user_and_workspace( request.app, user_id=req_ctx.user_id, - folder_id=path_params.folder_id, + workspace_id=path_params.workspace_id, product_name=req_ctx.product_name, ) - return envelope_json_response(folders, web.HTTPOk) + return envelope_json_response(workspaces, web.HTTPOk) @routes.put( - f"/{VTAG}/folders/{{folder_id}}/groups/{{group_id}}", - name="replace_folder_group", + f"/{VTAG}/workspaces/{{workspace_id}}/groups/{{group_id}}", + name="replace_workspace_group", ) @login_required -@permission_required("folder.access_rights.update") -@_handle_folders_groups_exceptions -async def replace_folder_group(request: web.Request): +@permission_required("workspaces.*") +@_handle_workspaces_groups_exceptions +async def replace_workspace_group(request: web.Request): req_ctx = _RequestContext.parse_obj(request) - path_params = parse_request_path_parameters_as(_FoldersGroupsPathParams, request) - body_params = await parse_request_body_as(_FoldersGroupsBodyParams, request) + path_params = parse_request_path_parameters_as(_WorkspacesGroupsPathParams, request) + body_params = await parse_request_body_as(_WorkspacesGroupsBodyParams, request) - return await _groups_api.update_folder_group_by_user( + return await _groups_api.update_workspace_group( app=request.app, user_id=req_ctx.user_id, - folder_id=path_params.folder_id, + workspace_id=path_params.workspace_id, group_id=path_params.group_id, read=body_params.read, write=body_params.write, @@ -142,20 +145,20 @@ async def replace_folder_group(request: web.Request): @routes.delete( - f"/{VTAG}/folders/{{folder_id}}/groups/{{group_id}}", - name="delete_folder_group", + f"/{VTAG}/workspaces/{{workspace_id}}/groups/{{group_id}}", + name="delete_workspace_group", ) @login_required -@permission_required("folder.access_rights.update") -@_handle_folders_groups_exceptions -async def delete_folder_group(request: web.Request): +@permission_required("workspaces.*") +@_handle_workspaces_groups_exceptions +async def delete_workspace_group(request: web.Request): req_ctx = _RequestContext.parse_obj(request) - path_params = parse_request_path_parameters_as(_FoldersGroupsPathParams, request) + path_params = parse_request_path_parameters_as(_WorkspacesGroupsPathParams, request) - await _groups_api.delete_folder_group_by_user( + await _groups_api.delete_workspace_group( app=request.app, user_id=req_ctx.user_id, - folder_id=path_params.folder_id, + workspace_id=path_params.workspace_id, group_id=path_params.group_id, product_name=req_ctx.product_name, ) diff --git a/services/web/server/src/simcore_service_webserver/workspaces/_workspaces_api.py b/services/web/server/src/simcore_service_webserver/workspaces/_workspaces_api.py new file mode 100644 index 00000000000..256b50de114 --- /dev/null +++ b/services/web/server/src/simcore_service_webserver/workspaces/_workspaces_api.py @@ -0,0 +1,195 @@ +# pylint: disable=unused-argument + +import logging + +from aiohttp import web +from models_library.api_schemas_webserver.workspaces import ( + WorkspaceGet, + WorkspaceGetPage, +) +from models_library.products import ProductName +from models_library.rest_ordering import OrderBy +from models_library.users import UserID +from models_library.workspaces import UserWorkspaceAccessRightsDB, WorkspaceID +from pydantic import NonNegativeInt +from simcore_service_webserver.projects._db_utils import PermissionStr +from simcore_service_webserver.workspaces.errors import WorkspaceAccessForbiddenError + +from ..users.api import get_user +from . import _workspaces_db as db + +_logger = logging.getLogger(__name__) + + +async def create_workspace( + app: web.Application, + user_id: UserID, + name: str, + description: str | None, + thumbnail: str | None, + product_name: ProductName, +) -> WorkspaceGet: + user = await get_user(app, user_id=user_id) + + created_workspace_db = await db.create_workspace( + app, + product_name=product_name, + owner_primary_gid=user["primary_gid"], + name=name, + description=description, + thumbnail=thumbnail, + ) + workspace_db = await db.get_workspace_for_user( + app, + user_id=user_id, + workspace_id=created_workspace_db.workspace_id, + product_name=product_name, + ) + return WorkspaceGet( + workspace_id=workspace_db.workspace_id, + name=workspace_db.name, + description=workspace_db.description, + thumbnail=workspace_db.thumbnail, + created_at=workspace_db.created, + modified_at=workspace_db.modified, + my_access_rights=workspace_db.my_access_rights, + access_rights=workspace_db.access_rights, + ) + + +async def get_workspace( + app: web.Application, + user_id: UserID, + workspace_id: WorkspaceID, + product_name: ProductName, +) -> WorkspaceGet: + workspace_db = await check_user_workspace_access( + app=app, + user_id=user_id, + workspace_id=workspace_id, + product_name=product_name, + permission="read", + ) + return WorkspaceGet( + workspace_id=workspace_db.workspace_id, + name=workspace_db.name, + description=workspace_db.description, + thumbnail=workspace_db.thumbnail, + created_at=workspace_db.created, + modified_at=workspace_db.modified, + my_access_rights=workspace_db.my_access_rights, + access_rights=workspace_db.access_rights, + ) + + +async def list_workspaces( + app: web.Application, + user_id: UserID, + product_name: ProductName, + offset: NonNegativeInt, + limit: int, + order_by: OrderBy, +) -> WorkspaceGetPage: + total_count, workspaces = await db.list_workspaces_for_user( + app, + user_id=user_id, + product_name=product_name, + offset=offset, + limit=limit, + order_by=order_by, + ) + + return WorkspaceGetPage( + items=[ + WorkspaceGet( + workspace_id=workspace.workspace_id, + name=workspace.name, + description=workspace.description, + thumbnail=workspace.thumbnail, + created_at=workspace.created, + modified_at=workspace.modified, + my_access_rights=workspace.my_access_rights, + access_rights=workspace.access_rights, + ) + for workspace in workspaces + ], + total=total_count, + ) + + +async def update_workspace( + app: web.Application, + user_id: UserID, + workspace_id: WorkspaceID, + name: str, + description: str | None, + thumbnail: str | None, + product_name: ProductName, +) -> WorkspaceGet: + await check_user_workspace_access( + app=app, + user_id=user_id, + workspace_id=workspace_id, + product_name=product_name, + permission="write", + ) + await db.update_workspace( + app, + workspace_id=workspace_id, + name=name, + description=description, + thumbnail=thumbnail, + product_name=product_name, + ) + workspace_db = await db.get_workspace_for_user( + app, + user_id=user_id, + workspace_id=workspace_id, + product_name=product_name, + ) + return WorkspaceGet( + workspace_id=workspace_db.workspace_id, + name=workspace_db.name, + description=workspace_db.description, + thumbnail=workspace_db.thumbnail, + created_at=workspace_db.created, + modified_at=workspace_db.modified, + my_access_rights=workspace_db.my_access_rights, + access_rights=workspace_db.access_rights, + ) + + +async def delete_workspace( + app: web.Application, + user_id: UserID, + workspace_id: WorkspaceID, + product_name: ProductName, +) -> None: + await check_user_workspace_access( + app=app, + user_id=user_id, + workspace_id=workspace_id, + product_name=product_name, + permission="delete", + ) + + await db.delete_workspace(app, workspace_id=workspace_id, product_name=product_name) + + +async def check_user_workspace_access( + app: web.Application, + *, + user_id: UserID, + workspace_id: WorkspaceID, + product_name: ProductName, + permission: PermissionStr = "read", +) -> UserWorkspaceAccessRightsDB: + """ + Raises WorkspaceAccessForbiddenError if no access + """ + workspace_db: UserWorkspaceAccessRightsDB = await db.get_workspace_for_user( + app=app, user_id=user_id, workspace_id=workspace_id, product_name=product_name + ) + if getattr(workspace_db.my_access_rights, permission, False) is False: + raise WorkspaceAccessForbiddenError + return workspace_db diff --git a/services/web/server/src/simcore_service_webserver/workspaces/_workspaces_db.py b/services/web/server/src/simcore_service_webserver/workspaces/_workspaces_db.py new file mode 100644 index 00000000000..ac13e33581e --- /dev/null +++ b/services/web/server/src/simcore_service_webserver/workspaces/_workspaces_db.py @@ -0,0 +1,237 @@ +""" Database API + + - Adds a layer to the postgres API with a focus on the projects comments + +""" + +import logging +from typing import cast + +from aiohttp import web +from models_library.products import ProductName +from models_library.rest_ordering import OrderBy, OrderDirection +from models_library.users import GroupID, UserID +from models_library.workspaces import ( + UserWorkspaceAccessRightsDB, + WorkspaceDB, + WorkspaceID, +) +from pydantic import NonNegativeInt +from simcore_postgres_database.models.groups import user_to_groups +from simcore_postgres_database.models.workspaces import workspaces +from simcore_postgres_database.models.workspaces_access_rights import ( + workspaces_access_rights, +) +from sqlalchemy import asc, desc, func +from sqlalchemy.dialects.postgresql import BOOLEAN, INTEGER +from sqlalchemy.sql import Subquery, select + +from ..db.plugin import get_database_engine +from .errors import WorkspaceAccessForbiddenError, WorkspaceNotFoundError + +_logger = logging.getLogger(__name__) + + +_SELECTION_ARGS = ( + workspaces.c.workspace_id, + workspaces.c.name, + workspaces.c.description, + workspaces.c.owner_primary_gid, + workspaces.c.thumbnail, + workspaces.c.created, + workspaces.c.modified, +) + + +async def create_workspace( + app: web.Application, + product_name: ProductName, + owner_primary_gid: GroupID, + name: str, + description: str | None, + thumbnail: str | None, +) -> WorkspaceDB: + async with get_database_engine(app).acquire() as conn: + result = await conn.execute( + workspaces.insert() + .values( + name=name, + description=description, + owner_primary_gid=owner_primary_gid, + thumbnail=thumbnail, + created=func.now(), + modified=func.now(), + product_name=product_name, + ) + .returning(*_SELECTION_ARGS) + ) + row = await result.first() + return WorkspaceDB.from_orm(row) + + +access_rights_subquery = ( + select( + workspaces_access_rights.c.workspace_id, + func.jsonb_object_agg( + workspaces_access_rights.c.gid, + func.jsonb_build_object( + "read", + workspaces_access_rights.c.read, + "write", + workspaces_access_rights.c.write, + "delete", + workspaces_access_rights.c.delete, + ), + ) + .filter( + workspaces_access_rights.c.read # Filters out entries where "read" is False + ) + .label("access_rights"), + ).group_by(workspaces_access_rights.c.workspace_id) +).subquery("access_rights_subquery") + + +def _create_my_access_rights_subquery(user_id: UserID) -> Subquery: + return ( + select( + workspaces_access_rights.c.workspace_id, + func.json_build_object( + "read", + func.max(workspaces_access_rights.c.read.cast(INTEGER)).cast(BOOLEAN), + "write", + func.max(workspaces_access_rights.c.write.cast(INTEGER)).cast(BOOLEAN), + "delete", + func.max(workspaces_access_rights.c.delete.cast(INTEGER)).cast(BOOLEAN), + ).label("my_access_rights"), + ) + .select_from( + workspaces_access_rights.join( + user_to_groups, user_to_groups.c.gid == workspaces_access_rights.c.gid + ) + ) + .where(user_to_groups.c.uid == user_id) + .group_by(workspaces_access_rights.c.workspace_id) + ).subquery("my_access_rights_subquery") + + +async def list_workspaces_for_user( + app: web.Application, + *, + user_id: UserID, + product_name: ProductName, + offset: NonNegativeInt, + limit: NonNegativeInt, + order_by: OrderBy, +) -> tuple[int, list[UserWorkspaceAccessRightsDB]]: + my_access_rights_subquery = _create_my_access_rights_subquery(user_id=user_id) + + base_query = ( + select( + *_SELECTION_ARGS, + access_rights_subquery.c.access_rights, + my_access_rights_subquery.c.my_access_rights, + ) + .select_from( + workspaces.join(access_rights_subquery, isouter=True).join( + my_access_rights_subquery + ) + ) + .where(workspaces.c.product_name == product_name) + ) + + # Select total count from base_query + subquery = base_query.subquery() + count_query = select(func.count()).select_from(subquery) + + # Ordering and pagination + if order_by.direction == OrderDirection.ASC: + list_query = base_query.order_by(asc(getattr(workspaces.c, order_by.field))) + else: + list_query = base_query.order_by(desc(getattr(workspaces.c, order_by.field))) + list_query = list_query.offset(offset).limit(limit) + + async with get_database_engine(app).acquire() as conn: + count_result = await conn.execute(count_query) + total_count = await count_result.scalar() + + result = await conn.execute(list_query) + rows = await result.fetchall() or [] + results: list[UserWorkspaceAccessRightsDB] = [ + UserWorkspaceAccessRightsDB.from_orm(row) for row in rows + ] + + return cast(int, total_count), results + + +async def get_workspace_for_user( + app: web.Application, + user_id: UserID, + workspace_id: WorkspaceID, + product_name: ProductName, +) -> UserWorkspaceAccessRightsDB: + my_access_rights_subquery = _create_my_access_rights_subquery(user_id=user_id) + + base_query = ( + select( + *_SELECTION_ARGS, + access_rights_subquery.c.access_rights, + my_access_rights_subquery.c.my_access_rights, + ) + .select_from(workspaces.join(my_access_rights_subquery)) + .where( + (workspaces.c.workspace_id == workspace_id) + & (workspaces.c.product_name == product_name) + ) + ) + + async with get_database_engine(app).acquire() as conn: + result = await conn.execute(base_query) + row = await result.first() + if row is None: + raise WorkspaceAccessForbiddenError( + reason=f"User does not have access to the workspace {workspace_id}. Or workspace does not exist.", + ) + return UserWorkspaceAccessRightsDB.from_orm(row) + + +async def update_workspace( + app: web.Application, + workspace_id: WorkspaceID, + name: str, + description: str | None, + thumbnail: str | None, + product_name: ProductName, +) -> WorkspaceDB: + async with get_database_engine(app).acquire() as conn: + result = await conn.execute( + workspaces.update() + .values( + name=name, + description=description, + thumbnail=thumbnail, + modified=func.now(), + ) + .where( + (workspaces.c.workspace_id == workspace_id) + & (workspaces.c.product_name == product_name) + ) + .returning(*_SELECTION_ARGS) + ) + row = await result.first() + if row is None: + raise WorkspaceNotFoundError(reason=f"Workspace {workspace_id} not found.") + return WorkspaceDB.from_orm(row) + + +async def delete_workspace( + app: web.Application, + workspace_id: WorkspaceID, + product_name: ProductName, +) -> None: + async with get_database_engine(app).acquire() as conn: + await conn.execute( + workspaces.delete().where( + (workspaces.c.workspace_id == workspace_id) + & (workspaces.c.product_name == product_name) + ) + ) diff --git a/services/web/server/src/simcore_service_webserver/workspaces/_workspaces_handlers.py b/services/web/server/src/simcore_service_webserver/workspaces/_workspaces_handlers.py new file mode 100644 index 00000000000..5cc49639334 --- /dev/null +++ b/services/web/server/src/simcore_service_webserver/workspaces/_workspaces_handlers.py @@ -0,0 +1,212 @@ +import functools +import logging + +from aiohttp import web +from models_library.api_schemas_webserver.workspaces import ( + CreateWorkspaceBodyParams, + PutWorkspaceBodyParams, + WorkspaceGet, + WorkspaceGetPage, +) +from models_library.basic_types import IDStr +from models_library.rest_ordering import OrderBy, OrderDirection +from models_library.rest_pagination import Page, PageQueryParameters +from models_library.rest_pagination_utils import paginate_data +from models_library.users import UserID +from models_library.workspaces import WorkspaceID +from pydantic import Extra, Field, Json, parse_obj_as, validator +from servicelib.aiohttp.requests_validation import ( + RequestParams, + StrictRequestParams, + parse_request_body_as, + parse_request_path_parameters_as, + parse_request_query_parameters_as, +) +from servicelib.aiohttp.typing_extension import Handler +from servicelib.mimetype_constants import MIMETYPE_APPLICATION_JSON +from servicelib.request_keys import RQT_USERID_KEY +from servicelib.rest_constants import RESPONSE_MODEL_POLICY + +from .._constants import RQ_PRODUCT_KEY +from .._meta import API_VTAG as VTAG +from ..login.decorators import login_required +from ..security.decorators import permission_required +from ..utils_aiohttp import envelope_json_response +from . import _workspaces_api +from .errors import WorkspaceAccessForbiddenError, WorkspaceNotFoundError + +_logger = logging.getLogger(__name__) + + +def handle_workspaces_exceptions(handler: Handler): + @functools.wraps(handler) + async def wrapper(request: web.Request) -> web.StreamResponse: + try: + return await handler(request) + + except WorkspaceNotFoundError as exc: + raise web.HTTPNotFound(reason=f"{exc}") from exc + + except WorkspaceAccessForbiddenError as exc: + raise web.HTTPForbidden(reason=f"{exc}") from exc + + return wrapper + + +# +# workspaces COLLECTION ------------------------- +# + +routes = web.RouteTableDef() + + +class WorkspacesRequestContext(RequestParams): + user_id: UserID = Field(..., alias=RQT_USERID_KEY) # type: ignore[literal-required] + product_name: str = Field(..., alias=RQ_PRODUCT_KEY) # type: ignore[literal-required] + + +class WorkspacesPathParams(StrictRequestParams): + workspace_id: WorkspaceID + + +class WorkspacesListWithJsonStrQueryParams(PageQueryParameters): + # pylint: disable=unsubscriptable-object + order_by: Json[OrderBy] = Field( + default=OrderBy(field=IDStr("modified"), direction=OrderDirection.DESC), + description="Order by field (modified_at|name|description) and direction (asc|desc). The default sorting order is ascending.", + example='{"field": "name", "direction": "desc"}', + alias="order_by", + ) + + @validator("order_by", check_fields=False) + @classmethod + def validate_order_by_field(cls, v): + if v.field not in { + "modified_at", + "name", + "description", + }: + msg = f"We do not support ordering by provided field {v.field}" + raise ValueError(msg) + if v.field == "modified_at": + v.field = "modified" + return v + + class Config: + extra = Extra.forbid + + +@routes.post(f"/{VTAG}/workspaces", name="create_workspace") +@login_required +@permission_required("workspaces.*") +@handle_workspaces_exceptions +async def create_workspace(request: web.Request): + req_ctx = WorkspacesRequestContext.parse_obj(request) + body_params = await parse_request_body_as(CreateWorkspaceBodyParams, request) + + workspace: WorkspaceGet = await _workspaces_api.create_workspace( + request.app, + user_id=req_ctx.user_id, + name=body_params.name, + description=body_params.description, + thumbnail=body_params.thumbnail, + product_name=req_ctx.product_name, + ) + + return envelope_json_response(workspace, web.HTTPCreated) + + +@routes.get(f"/{VTAG}/workspaces", name="list_workspaces") +@login_required +@permission_required("workspaces.*") +@handle_workspaces_exceptions +async def list_workspaces(request: web.Request): + req_ctx = WorkspacesRequestContext.parse_obj(request) + query_params: WorkspacesListWithJsonStrQueryParams = ( + parse_request_query_parameters_as(WorkspacesListWithJsonStrQueryParams, request) + ) + + workspaces: WorkspaceGetPage = await _workspaces_api.list_workspaces( + app=request.app, + user_id=req_ctx.user_id, + product_name=req_ctx.product_name, + offset=query_params.offset, + limit=query_params.limit, + order_by=parse_obj_as(OrderBy, query_params.order_by), + ) + + page = Page[WorkspaceGet].parse_obj( + paginate_data( + chunk=workspaces.items, + request_url=request.url, + total=workspaces.total, + limit=query_params.limit, + offset=query_params.offset, + ) + ) + return web.Response( + text=page.json(**RESPONSE_MODEL_POLICY), + content_type=MIMETYPE_APPLICATION_JSON, + ) + + +@routes.get(f"/{VTAG}/workspaces/{{workspace_id}}", name="get_workspace") +@login_required +@permission_required("workspaces.*") +@handle_workspaces_exceptions +async def get_workspace(request: web.Request): + req_ctx = WorkspacesRequestContext.parse_obj(request) + path_params = parse_request_path_parameters_as(WorkspacesPathParams, request) + + workspace: WorkspaceGet = await _workspaces_api.get_workspace( + app=request.app, + workspace_id=path_params.workspace_id, + user_id=req_ctx.user_id, + product_name=req_ctx.product_name, + ) + + return envelope_json_response(workspace) + + +@routes.put( + f"/{VTAG}/workspaces/{{workspace_id}}", + name="replace_workspace", +) +@login_required +@permission_required("workspaces.*") +@handle_workspaces_exceptions +async def replace_workspace(request: web.Request): + req_ctx = WorkspacesRequestContext.parse_obj(request) + path_params = parse_request_path_parameters_as(WorkspacesPathParams, request) + body_params = await parse_request_body_as(PutWorkspaceBodyParams, request) + + workspace: WorkspaceGet = await _workspaces_api.update_workspace( + app=request.app, + user_id=req_ctx.user_id, + workspace_id=path_params.workspace_id, + name=body_params.name, + description=body_params.description, + product_name=req_ctx.product_name, + thumbnail=body_params.thumbnail, + ) + return envelope_json_response(workspace) + + +@routes.delete( + f"/{VTAG}/workspaces/{{workspace_id}}", + name="delete_workspace", +) +@login_required +@permission_required("workspaces.*") +@handle_workspaces_exceptions +async def delete_workspace(request: web.Request): + req_ctx = WorkspacesRequestContext.parse_obj(request) + path_params = parse_request_path_parameters_as(WorkspacesPathParams, request) + + await _workspaces_api.delete_workspace( + app=request.app, + user_id=req_ctx.user_id, + workspace_id=path_params.workspace_id, + product_name=req_ctx.product_name, + ) + raise web.HTTPNoContent(content_type=MIMETYPE_APPLICATION_JSON) diff --git a/services/web/server/src/simcore_service_webserver/workspaces/api.py b/services/web/server/src/simcore_service_webserver/workspaces/api.py new file mode 100644 index 00000000000..d3643161876 --- /dev/null +++ b/services/web/server/src/simcore_service_webserver/workspaces/api.py @@ -0,0 +1,6 @@ +# mypy: disable-error-code=truthy-function +from ._workspaces_api import get_workspace + +assert get_workspace # nosec + +__all__: tuple[str, ...] = ("get_workspace",) diff --git a/services/web/server/src/simcore_service_webserver/workspaces/errors.py b/services/web/server/src/simcore_service_webserver/workspaces/errors.py new file mode 100644 index 00000000000..9dff56fb87c --- /dev/null +++ b/services/web/server/src/simcore_service_webserver/workspaces/errors.py @@ -0,0 +1,24 @@ +from ..errors import WebServerBaseError + + +class WorkspacesValueError(WebServerBaseError, ValueError): + ... + + +class WorkspaceNotFoundError(WorkspacesValueError): + msg_template = "Workspace not found. {reason}" + + +class WorkspaceAccessForbiddenError(WorkspacesValueError): + msg_template = "Workspace access forbidden. {reason}" + + +# Workspace groups + + +class WorkspaceGroupNotFoundError(WorkspacesValueError): + msg_template = "Workspace {workspace_id} group {group_id} not found." + + +class WorkspaceFolderInconsistencyError(WorkspacesValueError): + msg_template = "Folder {folder_id} does not exists in the workspace {workspace_id}" diff --git a/services/web/server/src/simcore_service_webserver/workspaces/plugin.py b/services/web/server/src/simcore_service_webserver/workspaces/plugin.py new file mode 100644 index 00000000000..4773b056b49 --- /dev/null +++ b/services/web/server/src/simcore_service_webserver/workspaces/plugin.py @@ -0,0 +1,27 @@ +""" tags management subsystem + +""" +import logging + +from aiohttp import web +from servicelib.aiohttp.application_keys import APP_SETTINGS_KEY +from servicelib.aiohttp.application_setup import ModuleCategory, app_module_setup + +from . import _groups_handlers, _workspaces_handlers + +_logger = logging.getLogger(__name__) + + +@app_module_setup( + __name__, + ModuleCategory.ADDON, + settings_name="WEBSERVER_WORKSPACES", + depends=["simcore_service_webserver.rest"], + logger=_logger, +) +def setup_workspaces(app: web.Application): + assert app[APP_SETTINGS_KEY].WEBSERVER_WORKSPACES # nosec + + # routes + app.router.add_routes(_workspaces_handlers.routes) + app.router.add_routes(_groups_handlers.routes) diff --git a/services/web/server/tests/conftest.py b/services/web/server/tests/conftest.py index 7a0e1695e4b..4c9a38a7b0d 100644 --- a/services/web/server/tests/conftest.py +++ b/services/web/server/tests/conftest.py @@ -225,6 +225,7 @@ async def _setup( "thumbnail": None, "name": None, "prjOwner": None, + "workspaceId": None, } if from_study: # access rights are replaced @@ -294,7 +295,9 @@ async def _creator( parent_node_id=parent_node_id, ) # Create project here: - resp = await client.post(f"{url}", json=project_data, headers=headers) + resp = await client.post( + f"{url}", json=project_data, headers=headers + ) # NOTE: MD <-- here is project created! print(f"<-- created project response: {resp=}") data, error = await assert_status(resp, expected_accepted_response) if error: diff --git a/services/web/server/tests/data/fake-project.json b/services/web/server/tests/data/fake-project.json index f1068cfbea1..ba8f4b90a39 100644 --- a/services/web/server/tests/data/fake-project.json +++ b/services/web/server/tests/data/fake-project.json @@ -70,5 +70,6 @@ }, "ui": {}, "quality": {}, - "dev": {} + "dev": {}, + "workspaceId": null } diff --git a/services/web/server/tests/data/fake-template-projects.hack08.notebooks.json b/services/web/server/tests/data/fake-template-projects.hack08.notebooks.json index 6d8cab92165..94b723223e6 100644 --- a/services/web/server/tests/data/fake-template-projects.hack08.notebooks.json +++ b/services/web/server/tests/data/fake-template-projects.hack08.notebooks.json @@ -61,5 +61,6 @@ "y": 150 } } - } + }, + "workspaceId": null } diff --git a/services/web/server/tests/data/fake-template-projects.isan.2dplot.json b/services/web/server/tests/data/fake-template-projects.isan.2dplot.json index 010e145dd1c..bace974f385 100644 --- a/services/web/server/tests/data/fake-template-projects.isan.2dplot.json +++ b/services/web/server/tests/data/fake-template-projects.isan.2dplot.json @@ -47,5 +47,6 @@ "y": 100 } } - } + }, + "workspaceId": null } diff --git a/services/web/server/tests/data/fake-template-projects.isan.matward.json b/services/web/server/tests/data/fake-template-projects.isan.matward.json index 02a94409b87..2e9bd544407 100644 --- a/services/web/server/tests/data/fake-template-projects.isan.matward.json +++ b/services/web/server/tests/data/fake-template-projects.isan.matward.json @@ -22,5 +22,6 @@ "y": 100 } } - } + }, + "workspaceId": null } diff --git a/services/web/server/tests/data/fake-template-projects.isan.paraview.json b/services/web/server/tests/data/fake-template-projects.isan.paraview.json index 32cbeecd827..4ed1f2bbd45 100644 --- a/services/web/server/tests/data/fake-template-projects.isan.paraview.json +++ b/services/web/server/tests/data/fake-template-projects.isan.paraview.json @@ -70,5 +70,6 @@ "y": 175 } } - } + }, + "workspaceId": null } diff --git a/services/web/server/tests/data/fake-template-projects.isan.ucdavis.json b/services/web/server/tests/data/fake-template-projects.isan.ucdavis.json index f33f5c38d23..872bd91f706 100644 --- a/services/web/server/tests/data/fake-template-projects.isan.ucdavis.json +++ b/services/web/server/tests/data/fake-template-projects.isan.ucdavis.json @@ -83,5 +83,6 @@ "y": 150 } } - } + }, + "workspaceId": null } diff --git a/services/web/server/tests/data/fake-template-projects.sleepers.json b/services/web/server/tests/data/fake-template-projects.sleepers.json index 77f60f8b322..b90c68cec77 100644 --- a/services/web/server/tests/data/fake-template-projects.sleepers.json +++ b/services/web/server/tests/data/fake-template-projects.sleepers.json @@ -112,5 +112,6 @@ "y": 300 } } - } + }, + "workspaceId": null } diff --git a/services/web/server/tests/unit/isolated/test_garbage_collector_core.py b/services/web/server/tests/unit/isolated/test_garbage_collector_core.py index 78c0d96e46c..7f686a44292 100644 --- a/services/web/server/tests/unit/isolated/test_garbage_collector_core.py +++ b/services/web/server/tests/unit/isolated/test_garbage_collector_core.py @@ -138,18 +138,11 @@ def has_write_permission(request: pytest.FixtureRequest) -> bool: async def mock_has_write_permission( mocker: MockerFixture, has_write_permission: bool ) -> mock.AsyncMock: - mocked_project_db_api = mocker.patch( - f"{MODULE_GC_CORE_ORPHANS}.ProjectDBAPI", autospec=True - ) - - async def _mocked_has_permission(*args, **kwargs) -> bool: - assert "write" in args - return has_write_permission - - mocked_project_db_api.get_from_app_context.return_value.has_permission.side_effect = ( - _mocked_has_permission + return mocker.patch( + f"{MODULE_GC_CORE_ORPHANS}.has_user_project_access_rights", + autospec=True, + return_value=has_write_permission, ) - return mocked_project_db_api.get_from_app_context.return_value.has_permission @pytest.fixture(params=list(UserRole), ids=str) @@ -196,7 +189,10 @@ async def test_remove_orphaned_services( if node_exists and user_role > UserRole.GUEST: mock_get_user_role.assert_called_once() mock_has_write_permission.assert_called_once_with( - fake_running_service.user_id, f"{fake_running_service.project_id}", "write" + mock.ANY, + project_id=fake_running_service.project_id, + user_id=fake_running_service.user_id, + permission="write", ) elif node_exists: mock_get_user_role.assert_called_once() diff --git a/services/web/server/tests/unit/isolated/test_projects__db_utils.py b/services/web/server/tests/unit/isolated/test_projects__db_utils.py index bc435aaf5d7..cee237fda90 100644 --- a/services/web/server/tests/unit/isolated/test_projects__db_utils.py +++ b/services/web/server/tests/unit/isolated/test_projects__db_utils.py @@ -7,25 +7,21 @@ import re from copy import deepcopy from dataclasses import dataclass -from itertools import combinations from typing import Any, Callable import pytest from faker import Faker from models_library.projects_nodes import Node from models_library.services import ServiceKey -from models_library.users import GroupID, UserID +from models_library.users import GroupID from models_library.utils.fastapi_encoders import jsonable_encoder -from simcore_postgres_database.models.groups import GroupType from simcore_service_webserver.projects._db_utils import ( DB_EXCLUSIVE_COLUMNS, SCHEMA_NON_NULL_KEYS, ) from simcore_service_webserver.projects.db import ( - ANY_USER, ProjectAccessRights, assemble_array_groups, - check_project_permissions, convert_to_db_names, convert_to_schema_names, create_project_access_rights, @@ -34,7 +30,6 @@ ) from simcore_service_webserver.projects.exceptions import ( NodeNotFoundError, - ProjectInvalidRightsError, ProjectInvalidUsageError, ) @@ -129,148 +124,11 @@ def test_convert_to_schema_names_camel_casing(fake_db_dict): assert db_entries["prjOwner"] == fake_email -def test_check_project_permissions_for_any_user(): - project = {"access_rights": {"1": {"read": True, "write": False, "delete": False}}} - - check_project_permissions( - project, - user_id=ANY_USER, - user_groups=[{"gid": 1, "type": GroupType.EVERYONE}], - permission="read", - ) - - -def all_permission_combinations() -> list[str]: - entries_list = ["read", "write", "delete"] - temp = [] - for i in range(1, len(entries_list) + 1): - temp.extend(list(combinations(entries_list, i))) - return ["|".join(el) for el in temp] - - @pytest.fixture def group_id(faker: Faker) -> GroupID: return faker.pyint(min_value=1) -@pytest.mark.parametrize("wanted_permissions", all_permission_combinations()) -def test_check_project_permissions( - user_id: UserID, - group_id: GroupID, - wanted_permissions: str, -): - project = {"access_rights": {}} - - # this should not raise as needed permissions is empty - check_project_permissions(project, user_id, user_groups=[], permission="") - - # this should raise cause we have no user groups defined and we want permission - with pytest.raises(ProjectInvalidRightsError): - check_project_permissions( - project, user_id, user_groups=[], permission=wanted_permissions - ) - - def _project_access_rights_from_permissions( - permissions: str, *, invert: bool - ) -> dict[str, bool]: - access_rights = {} - for p in ["read", "write", "delete"]: - access_rights[p] = ( - p in permissions if invert is False else p not in permissions - ) - return access_rights - - # primary group has needed access, so this should not raise - project = { - "access_rights": { - str(group_id): _project_access_rights_from_permissions( - wanted_permissions, invert=False - ) - } - } - user_groups = [ - {"type": GroupType.PRIMARY, "gid": group_id}, - {"type": GroupType.EVERYONE, "gid": 2}, - ] - check_project_permissions(project, user_id, user_groups, wanted_permissions) - - # primary group does not have access, it should raise - project = { - "access_rights": { - str(group_id): _project_access_rights_from_permissions( - wanted_permissions, invert=True - ) - } - } - with pytest.raises(ProjectInvalidRightsError): - check_project_permissions(project, user_id, user_groups, wanted_permissions) - - # if no primary group, we rely on standard groups and the most permissive access are used. so this should not raise - project = { - "access_rights": { - str(group_id): _project_access_rights_from_permissions( - wanted_permissions, invert=True - ), - str(group_id + 1): _project_access_rights_from_permissions( - wanted_permissions, invert=False - ), - str(group_id + 2): _project_access_rights_from_permissions( - wanted_permissions, invert=True - ), - } - } - user_groups = [ - {"type": GroupType.PRIMARY, "gid": group_id}, - {"type": GroupType.EVERYONE, "gid": 2}, - {"type": GroupType.STANDARD, "gid": group_id + 1}, - {"type": GroupType.STANDARD, "gid": group_id + 2}, - ] - check_project_permissions(project, user_id, user_groups, wanted_permissions) - - # if both primary and standard do not have rights it should raise - project = { - "access_rights": { - str(group_id): _project_access_rights_from_permissions( - wanted_permissions, invert=True - ), - str(group_id + 1): _project_access_rights_from_permissions( - wanted_permissions, invert=True - ), - str(group_id + 2): _project_access_rights_from_permissions( - wanted_permissions, invert=True - ), - } - } - user_groups = [ - {"type": GroupType.PRIMARY, "gid": group_id}, - {"type": GroupType.EVERYONE, "gid": 2}, - {"type": GroupType.STANDARD, "gid": group_id + 1}, - {"type": GroupType.STANDARD, "gid": group_id + 2}, - ] - with pytest.raises(ProjectInvalidRightsError): - check_project_permissions(project, user_id, user_groups, wanted_permissions) - - # the everyone group has access so it should not raise - project = { - "access_rights": { - str(2): _project_access_rights_from_permissions( - wanted_permissions, invert=False - ), - str(group_id): _project_access_rights_from_permissions( - wanted_permissions, invert=True - ), - str(group_id + 1): _project_access_rights_from_permissions( - wanted_permissions, invert=True - ), - str(group_id + 2): _project_access_rights_from_permissions( - wanted_permissions, invert=True - ), - } - } - - check_project_permissions(project, user_id, user_groups, wanted_permissions) - - @pytest.mark.parametrize("project_access_rights", list(ProjectAccessRights)) def test_project_access_rights_creation( group_id: int, project_access_rights: ProjectAccessRights diff --git a/services/web/server/tests/unit/with_dbs/02/test_projects_crud_handlers.py b/services/web/server/tests/unit/with_dbs/02/test_projects_crud_handlers.py index 482a4ced098..52ecb20038a 100644 --- a/services/web/server/tests/unit/with_dbs/02/test_projects_crud_handlers.py +++ b/services/web/server/tests/unit/with_dbs/02/test_projects_crud_handlers.py @@ -602,6 +602,7 @@ async def test_new_template_from_project( }, "tags": [], "classifiers": [], + "workspaceId": None, } new_template_prj = await request_create_project( client, diff --git a/services/web/server/tests/unit/with_dbs/02/test_projects_crud_handlers__list_with_folders_plugin_disabled.py b/services/web/server/tests/unit/with_dbs/02/test_projects_crud_handlers__list_with_folders_plugin_disabled.py deleted file mode 100644 index b989d566437..00000000000 --- a/services/web/server/tests/unit/with_dbs/02/test_projects_crud_handlers__list_with_folders_plugin_disabled.py +++ /dev/null @@ -1,108 +0,0 @@ -# pylint: disable=redefined-outer-name -# pylint: disable=too-many-arguments -# pylint: disable=unused-argument -# pylint: disable=unused-variable -# pylint: disable=too-many-statements - -from pathlib import Path -from typing import Iterator - -import pytest -import sqlalchemy as sa -from aiohttp.test_utils import TestClient -from models_library.folders import FolderID -from pytest_mock import MockerFixture -from pytest_simcore.helpers.typing_env import EnvVarsDict -from pytest_simcore.helpers.webserver_login import UserInfoDict -from pytest_simcore.helpers.webserver_parametrizations import ( - ExpectedResponse, - standard_role_response, -) -from simcore_postgres_database.models.folders import folders, folders_to_projects -from simcore_service_webserver._meta import api_version_prefix -from simcore_service_webserver.db.models import UserRole -from simcore_service_webserver.projects.models import ProjectDict - -# from .test_projects_crud_handlers__list_with_query_params import _assert_response_data - - -def standard_user_role() -> tuple[str, tuple[UserRole, ExpectedResponse]]: - all_roles = standard_role_response() - - return (all_roles[0], [pytest.param(*all_roles[1][2], id="standard user role")]) - - -@pytest.fixture -def mock_catalog_api_get_services_for_user_in_product(mocker: MockerFixture): - mocker.patch( - "simcore_service_webserver.projects._crud_api_read.get_services_for_user_in_product", - spec=True, - return_value=[], - ) - - -@pytest.fixture() -def setup_folders_db( - postgres_db: sa.engine.Engine, - logged_user: UserInfoDict, - user_project: ProjectDict, -) -> Iterator[FolderID]: - with postgres_db.connect() as con: - result = con.execute( - folders.insert() - .values( - name="My Folder 1", - description="My Folder Decription", - product_name="osparc", - created_by=logged_user["primary_gid"], - ) - .returning(folders.c.id) - ) - _folder_id = result.fetchone()[0] - - con.execute( - folders_to_projects.insert().values( - folder_id=_folder_id, project_uuid=user_project["uuid"] - ) - ) - - yield FolderID(_folder_id) - - con.execute(folders_to_projects.delete()) - con.execute(folders.delete()) - - -@pytest.fixture -def app_environment( - app_environment: EnvVarsDict, monkeypatch: pytest.MonkeyPatch -) -> EnvVarsDict: - # disable the webserver folders plugin - monkeypatch.setenv("WEBSERVER_FOLDERS", "0") - return app_environment | {"WEBSERVER_FOLDERS": "0"} - - -@pytest.mark.parametrize(*standard_user_role()) -async def test_list_projects_with_disabled_project_folders_plugin( - client: TestClient, - app_environment: EnvVarsDict, - expected: ExpectedResponse, - fake_project: ProjectDict, - tests_data_dir: Path, - osparc_product_name: str, - project_db_cleaner, - mock_catalog_api_get_services_for_user_in_product, - setup_folders_db, -): - """ - As the WEBSERVER_FOLDERS plugin is turned off, the project listing - should behave the same way as before, and therefore list all the projects - in the root directory, essentially ignoring the folders_to_projects table. - """ - base_url = client.app.router["list_projects"].url_for() - assert f"{base_url}" == f"/{api_version_prefix}/projects" - - resp = await client.get(base_url) - data = await resp.json() - - assert resp.status == 200 - assert data["_meta"]["total"] == 1 diff --git a/services/web/server/tests/unit/with_dbs/02/test_projects_crud_handlers__list_with_query_params.py b/services/web/server/tests/unit/with_dbs/02/test_projects_crud_handlers__list_with_query_params.py index b93d33ef94b..89b7fed1544 100644 --- a/services/web/server/tests/unit/with_dbs/02/test_projects_crud_handlers__list_with_query_params.py +++ b/services/web/server/tests/unit/with_dbs/02/test_projects_crud_handlers__list_with_query_params.py @@ -25,7 +25,8 @@ standard_role_response, ) from pytest_simcore.helpers.webserver_projects import create_project -from simcore_postgres_database.models.folders import folders, folders_to_projects +from simcore_postgres_database.models.folders_v2 import folders_v2 +from simcore_postgres_database.models.projects_to_folders import projects_to_folders from simcore_service_webserver._meta import api_version_prefix from simcore_service_webserver.db.models import UserRole from simcore_service_webserver.projects.models import ProjectDict @@ -373,29 +374,32 @@ def setup_folders_db( user_project: ProjectDict, ) -> Iterator[FolderID]: with postgres_db.connect() as con: - output = [] result = con.execute( - folders.insert() + folders_v2.insert() .values( name="My Folder 1", - description="My Folder Decription", + parent_folder_id=None, + user_id=logged_user["id"], + workspace_id=None, product_name="osparc", - created_by=logged_user["primary_gid"], + created_by_gid=logged_user["primary_gid"], ) - .returning(folders.c.id) + .returning(folders_v2.c.folder_id) ) _folder_id = result.fetchone()[0] con.execute( - folders_to_projects.insert().values( - folder_id=_folder_id, project_uuid=user_project["uuid"] + projects_to_folders.insert().values( + folder_id=_folder_id, + project_uuid=user_project["uuid"], + user_id=logged_user["id"], ) ) yield FolderID(_folder_id) - con.execute(folders_to_projects.delete()) - con.execute(folders.delete()) + con.execute(projects_to_folders.delete()) + con.execute(folders_v2.delete()) @pytest.mark.parametrize(*standard_user_role()) diff --git a/services/web/server/tests/unit/with_dbs/03/folders/test_folders.py b/services/web/server/tests/unit/with_dbs/03/folders/test_folders.py index 5844342faf4..acd3b1f96b9 100644 --- a/services/web/server/tests/unit/with_dbs/03/folders/test_folders.py +++ b/services/web/server/tests/unit/with_dbs/03/folders/test_folders.py @@ -7,14 +7,34 @@ import pytest from aiohttp.test_utils import TestClient -from models_library.api_schemas_webserver.folders import FolderGet +from models_library.api_schemas_webserver.folders_v2 import FolderGet +from pytest_mock import MockerFixture from pytest_simcore.helpers.assert_checks import assert_status -from pytest_simcore.helpers.webserver_login import UserInfoDict +from pytest_simcore.helpers.webserver_login import LoggedUser, UserInfoDict +from pytest_simcore.helpers.webserver_parametrizations import ( + ExpectedResponse, + standard_role_response, +) from servicelib.aiohttp import status from simcore_service_webserver.db.models import UserRole +from simcore_service_webserver.projects._groups_db import update_or_insert_project_group from simcore_service_webserver.projects.models import ProjectDict +@pytest.mark.parametrize(*standard_role_response(), ids=str) +async def test_folders_user_role_permissions( + client: TestClient, + logged_user: UserInfoDict, + user_project: ProjectDict, + expected: ExpectedResponse, +): + assert client.app + + url = client.app.router["list_folders"].url_for() + resp = await client.get(url.path) + await assert_status(resp, expected.ok) + + @pytest.mark.parametrize("user_role,expected", [(UserRole.USER, status.HTTP_200_OK)]) async def test_folders_full_workflow( client: TestClient, @@ -32,9 +52,7 @@ async def test_folders_full_workflow( # create a new folder url = client.app.router["create_folder"].url_for() - resp = await client.post( - url.path, json={"name": "My first folder", "description": "Custom description"} - ) + resp = await client.post(url.path, json={"name": "My first folder"}) added_folder, _ = await assert_status(resp, status.HTTP_201_CREATED) assert FolderGet.parse_obj(added_folder) @@ -47,9 +65,6 @@ async def test_folders_full_workflow( assert len(data) == 1 assert data[0]["folderId"] == added_folder["folderId"] assert data[0]["name"] == "My first folder" - assert data[0]["description"] == "Custom description" - assert data[0]["myAccessRights"] - assert data[0]["accessRights"] assert meta["count"] == 1 assert links @@ -61,9 +76,6 @@ async def test_folders_full_workflow( data, _ = await assert_status(resp, status.HTTP_200_OK) assert data["folderId"] == added_folder["folderId"] assert data["name"] == "My first folder" - assert data["description"] == "Custom description" - assert data["myAccessRights"] - assert data["accessRights"] # update a folder url = client.app.router["replace_folder"].url_for( @@ -73,7 +85,6 @@ async def test_folders_full_workflow( url.path, json={ "name": "My Second folder", - "description": "", }, ) data, _ = await assert_status(resp, status.HTTP_200_OK) @@ -85,9 +96,6 @@ async def test_folders_full_workflow( data, _ = await assert_status(resp, status.HTTP_200_OK) assert len(data) == 1 assert data[0]["name"] == "My Second folder" - assert data[0]["description"] == "" - assert data[0]["myAccessRights"] - assert data[0]["accessRights"] # delete a folder url = client.app.router["delete_folder"].url_for( @@ -120,9 +128,7 @@ async def test_sub_folders_full_workflow( # create a new folder url = client.app.router["create_folder"].url_for() - resp = await client.post( - url.path, json={"name": "My first folder", "description": "Custom description"} - ) + resp = await client.post(url.path, json={"name": "My first folder"}) root_folder, _ = await assert_status(resp, status.HTTP_201_CREATED) # create a subfolder folder @@ -131,7 +137,6 @@ async def test_sub_folders_full_workflow( url.path, json={ "name": "My subfolder", - "description": "Custom subfolder description", "parentFolderId": root_folder["folderId"], }, ) @@ -143,7 +148,6 @@ async def test_sub_folders_full_workflow( data, _ = await assert_status(resp, status.HTTP_200_OK) assert len(data) == 1 assert data[0]["name"] == "My first folder" - assert data[0]["description"] == "Custom description" # list user specific folder base_url = client.app.router["list_folders"].url_for() @@ -158,7 +162,6 @@ async def test_sub_folders_full_workflow( url.path, json={ "name": "My sub sub folder", - "description": "Custom sub sub folder description", "parentFolderId": subfolder_folder["folderId"], }, ) @@ -171,9 +174,29 @@ async def test_sub_folders_full_workflow( data, _ = await assert_status(resp, status.HTTP_200_OK) assert len(data) == 1 assert data[0]["name"] == "My sub sub folder" - assert data[0]["description"] == "Custom sub sub folder description" assert data[0]["parentFolderId"] == subfolder_folder["folderId"] + # move sub sub folder to root folder + url = client.app.router["replace_folder"].url_for( + folder_id=f"{subsubfolder_folder['folderId']}" + ) + resp = await client.put( + url.path, + json={ + "name": "My Updated Folder", + "parentFolderId": None, + }, + ) + data, _ = await assert_status(resp, status.HTTP_200_OK) + assert FolderGet.parse_obj(data) + + # list user root folders + base_url = client.app.router["list_folders"].url_for() + url = base_url.with_query({"folder_id": "null"}) + resp = await client.get(url) + data, _ = await assert_status(resp, status.HTTP_200_OK) + assert len(data) == 2 + @pytest.mark.parametrize("user_role,expected", [(UserRole.USER, status.HTTP_200_OK)]) async def test_project_folder_movement_full_workflow( @@ -186,9 +209,7 @@ async def test_project_folder_movement_full_workflow( # create a new folder url = client.app.router["create_folder"].url_for() - resp = await client.post( - url.path, json={"name": "My first folder", "description": "Custom description"} - ) + resp = await client.post(url.path, json={"name": "My first folder"}) root_folder, _ = await assert_status(resp, status.HTTP_201_CREATED) # add project to the folder @@ -204,7 +225,6 @@ async def test_project_folder_movement_full_workflow( url.path, json={ "name": "My sub folder", - "description": "Custom sub folder description", "parentFolderId": root_folder["folderId"], }, ) @@ -223,3 +243,95 @@ async def test_project_folder_movement_full_workflow( ) resp = await client.put(url.path) await assert_status(resp, status.HTTP_204_NO_CONTENT) + + +@pytest.fixture +def mock_catalog_api_get_services_for_user_in_product(mocker: MockerFixture): + mocker.patch( + "simcore_service_webserver.projects._crud_api_read.get_services_for_user_in_product", + spec=True, + return_value=[], + ) + + +@pytest.mark.parametrize("user_role,expected", [(UserRole.USER, status.HTTP_200_OK)]) +async def test_project_listing_inside_of_private_folder( + client: TestClient, + logged_user: UserInfoDict, + user_project: ProjectDict, + expected: HTTPStatus, + mock_catalog_api_get_services_for_user_in_product: MockerFixture, +): + assert client.app + + # create a new folder + url = client.app.router["create_folder"].url_for() + resp = await client.post(url.path, json={"name": "My first folder"}) + original_user_folder, _ = await assert_status(resp, status.HTTP_201_CREATED) + + # add project to the folder + url = client.app.router["replace_project_folder"].url_for( + folder_id=f"{original_user_folder['folderId']}", + project_id=f"{user_project['uuid']}", + ) + resp = await client.put(url.path) + await assert_status(resp, status.HTTP_204_NO_CONTENT) + + # list project in user private folder + base_url = client.app.router["list_projects"].url_for() + url = base_url.with_query({"folder_id": f"{original_user_folder['folderId']}"}) + resp = await client.get(url) + data, _ = await assert_status(resp, status.HTTP_200_OK) + assert len(data) == 1 + assert data[0]["uuid"] == user_project["uuid"] + + # Create new user + async with LoggedUser(client) as new_logged_user: + # Try to list folder that user doesn't have access to + base_url = client.app.router["list_projects"].url_for() + url = base_url.with_query({"folder_id": f"{original_user_folder['folderId']}"}) + resp = await client.get(url) + _, errors = await assert_status( + resp, + status.HTTP_401_UNAUTHORIZED, + ) + assert errors + + # Now we will share the project with the new user + await update_or_insert_project_group( + client.app, + project_id=user_project["uuid"], + group_id=new_logged_user["primary_gid"], + read=True, + write=True, + delete=False, + ) + + # list new user root folder + base_url = client.app.router["list_projects"].url_for() + url = base_url.with_query({"folder_id": "null"}) + resp = await client.get(url) + data, _ = await assert_status(resp, status.HTTP_200_OK) + assert len(data) == 1 + assert data[0]["uuid"] == user_project["uuid"] + + # create a new folder + url = client.app.router["create_folder"].url_for() + resp = await client.post(url.path, json={"name": "New user folder"}) + new_user_folder, _ = await assert_status(resp, status.HTTP_201_CREATED) + + # add project to the folder + url = client.app.router["replace_project_folder"].url_for( + folder_id=f"{new_user_folder['folderId']}", + project_id=f"{user_project['uuid']}", + ) + resp = await client.put(url.path) + await assert_status(resp, status.HTTP_204_NO_CONTENT) + + # list new user specific folder + base_url = client.app.router["list_projects"].url_for() + url = base_url.with_query({"folder_id": f"{new_user_folder['folderId']}"}) + resp = await client.get(url) + data, _ = await assert_status(resp, status.HTTP_200_OK) + assert len(data) == 1 + assert data[0]["uuid"] == user_project["uuid"] diff --git a/services/web/server/tests/unit/with_dbs/03/folders/test_folders_groups.py b/services/web/server/tests/unit/with_dbs/03/folders/test_folders_groups.py deleted file mode 100644 index 7c3215f2740..00000000000 --- a/services/web/server/tests/unit/with_dbs/03/folders/test_folders_groups.py +++ /dev/null @@ -1,4 +0,0 @@ -# pylint: disable=unused-argument -# pylint: disable=unused-variable -# pylint: disable=too-many-arguments -# pylint: disable=too-many-statements diff --git a/services/web/server/tests/unit/with_dbs/03/test_project_db.py b/services/web/server/tests/unit/with_dbs/03/test_project_db.py index f1f1774a790..2b7ff6734f8 100644 --- a/services/web/server/tests/unit/with_dbs/03/test_project_db.py +++ b/services/web/server/tests/unit/with_dbs/03/test_project_db.py @@ -31,6 +31,7 @@ from simcore_postgres_database.utils_projects_nodes import ProjectNodesRepo from simcore_service_webserver.projects._db_utils import PermissionStr from simcore_service_webserver.projects._groups_db import update_or_insert_project_group +from simcore_service_webserver.projects.api import has_user_project_access_rights from simcore_service_webserver.projects.db import ProjectAccessRights, ProjectDBAPI from simcore_service_webserver.projects.exceptions import ( NodeNotFoundError, @@ -807,14 +808,33 @@ async def test_has_permission( assert permission in access_rights # owner always is allowed to do everything - assert await db_api.has_permission(owner_id, project_id, permission) is True + # assert await db_api.has_permission(owner_id, project_id, permission) is True + assert ( + await has_user_project_access_rights( + client.app, + project_id=project_id, + user_id=owner_id, + permission=permission, + ) + is True + ) # user does not exits - assert await db_api.has_permission(-1, project_id, permission) is False + assert ( + await has_user_project_access_rights( + client.app, project_id=project_id, user_id=-1, permission=permission + ) + is False + ) # other user assert ( - await db_api.has_permission(second_user["id"], project_id, permission) + await has_user_project_access_rights( + client.app, + project_id=project_id, + user_id=second_user["id"], + permission=permission, + ) is access_rights[permission] ), f"Found unexpected {permission=} for {access_rights=} of {user_role=} and {project_id=}" @@ -891,6 +911,7 @@ async def inserted_project( ) @pytest.mark.parametrize("user_role", [(UserRole.USER)]) async def test_check_project_node_has_all_required_inputs_raises( + client: TestClient, logged_user: dict[str, Any], db_api: ProjectDBAPI, inserted_project: dict, @@ -899,6 +920,7 @@ async def test_check_project_node_has_all_required_inputs_raises( with pytest.raises(ProjectNodeRequiredInputsNotSetError) as exc: await _check_project_node_has_all_required_inputs( + client.app, db_api, user_id=logged_user["id"], project_uuid=UUID(inserted_project["uuid"]), @@ -920,11 +942,13 @@ async def test_check_project_node_has_all_required_inputs_raises( ) @pytest.mark.parametrize("user_role", [(UserRole.USER)]) async def test_check_project_node_has_all_required_inputs_ok( + client: TestClient, logged_user: dict[str, Any], db_api: ProjectDBAPI, inserted_project: dict, ): await _check_project_node_has_all_required_inputs( + client.app, db_api, user_id=logged_user["id"], project_uuid=UUID(inserted_project["uuid"]), diff --git a/services/web/server/tests/unit/with_dbs/03/workspaces/conftest.py b/services/web/server/tests/unit/with_dbs/03/workspaces/conftest.py new file mode 100644 index 00000000000..744b30da23b --- /dev/null +++ b/services/web/server/tests/unit/with_dbs/03/workspaces/conftest.py @@ -0,0 +1,15 @@ +# pylint:disable=unused-variable +# pylint:disable=unused-argument +# pylint:disable=redefined-outer-name +from collections.abc import Iterator + +import pytest +import sqlalchemy as sa +from simcore_postgres_database.models.workspaces import workspaces + + +@pytest.fixture +def workspaces_clean_db(postgres_db: sa.engine.Engine) -> Iterator[None]: + with postgres_db.connect() as con: + yield + con.execute(workspaces.delete()) diff --git a/services/web/server/tests/unit/with_dbs/03/workspaces/test_workspaces.py b/services/web/server/tests/unit/with_dbs/03/workspaces/test_workspaces.py new file mode 100644 index 00000000000..e2ace9daa6a --- /dev/null +++ b/services/web/server/tests/unit/with_dbs/03/workspaces/test_workspaces.py @@ -0,0 +1,132 @@ +# pylint: disable=redefined-outer-name +# pylint: disable=unused-argument +# pylint: disable=unused-variable +# pylint: disable=too-many-arguments +# pylint: disable=too-many-statements +from http import HTTPStatus + +import pytest +from aiohttp.test_utils import TestClient +from models_library.api_schemas_webserver.workspaces import WorkspaceGet +from pytest_simcore.helpers.assert_checks import assert_status +from pytest_simcore.helpers.webserver_login import UserInfoDict +from pytest_simcore.helpers.webserver_parametrizations import ( + ExpectedResponse, + standard_role_response, +) +from servicelib.aiohttp import status +from simcore_service_webserver.db.models import UserRole +from simcore_service_webserver.projects.models import ProjectDict + + +@pytest.mark.parametrize(*standard_role_response(), ids=str) +async def test_workspaces_user_role_permissions( + client: TestClient, + logged_user: UserInfoDict, + user_project: ProjectDict, + expected: ExpectedResponse, +): + assert client.app + + url = client.app.router["list_workspaces"].url_for() + resp = await client.get(url.path) + await assert_status(resp, expected.ok) + + +@pytest.mark.parametrize("user_role,expected", [(UserRole.USER, status.HTTP_200_OK)]) +async def test_workspaces_workflow( + client: TestClient, + logged_user: UserInfoDict, + user_project: ProjectDict, + expected: HTTPStatus, +): + assert client.app + + # list user workspaces + url = client.app.router["list_workspaces"].url_for() + resp = await client.get(url.path) + data, _ = await assert_status(resp, status.HTTP_200_OK) + assert data == [] + + # create a new workspace + url = client.app.router["create_workspace"].url_for() + resp = await client.post( + url.path, + json={ + "name": "My first workspace", + "description": "Custom description", + "thumbnail": None, + }, + ) + added_workspace, _ = await assert_status(resp, status.HTTP_201_CREATED) + assert WorkspaceGet.parse_obj(added_workspace) + + # list user workspaces + url = client.app.router["list_workspaces"].url_for() + resp = await client.get(url.path) + data, _, meta, links = await assert_status( + resp, status.HTTP_200_OK, include_meta=True, include_links=True + ) + assert len(data) == 1 + assert data[0]["workspaceId"] == added_workspace["workspaceId"] + assert data[0]["name"] == "My first workspace" + assert data[0]["description"] == "Custom description" + assert meta["count"] == 1 + assert links + + # get a user workspace + url = client.app.router["get_workspace"].url_for( + workspace_id=f"{added_workspace['workspaceId']}" + ) + resp = await client.get(url) + data, _ = await assert_status(resp, status.HTTP_200_OK) + assert data["workspaceId"] == added_workspace["workspaceId"] + assert data["name"] == "My first workspace" + assert data["description"] == "Custom description" + + # update a workspace + url = client.app.router["replace_workspace"].url_for( + workspace_id=f"{added_workspace['workspaceId']}" + ) + resp = await client.put( + url.path, + json={ + "name": "My Second workspace", + "description": "", + }, + ) + data, _ = await assert_status(resp, status.HTTP_200_OK) + assert WorkspaceGet.parse_obj(data) + + # list user workspaces + url = client.app.router["list_workspaces"].url_for() + resp = await client.get(url.path) + data, _ = await assert_status(resp, status.HTTP_200_OK) + assert len(data) == 1 + assert data[0]["name"] == "My Second workspace" + assert data[0]["description"] == "" + + # delete a workspace + url = client.app.router["delete_workspace"].url_for( + workspace_id=f"{added_workspace['workspaceId']}" + ) + resp = await client.delete(url.path) + data, _ = await assert_status(resp, status.HTTP_204_NO_CONTENT) + + # list user workspaces + url = client.app.router["list_workspaces"].url_for() + resp = await client.get(url.path) + data, _ = await assert_status(resp, status.HTTP_200_OK) + assert data == [] + + +@pytest.mark.parametrize("user_role,expected", [(UserRole.USER, status.HTTP_200_OK)]) +async def test_project_workspace_movement_full_workflow( + client: TestClient, + logged_user: UserInfoDict, + user_project: ProjectDict, + expected: HTTPStatus, +): + assert client.app + + # NOTE: MD: not yet implemented diff --git a/services/web/server/tests/unit/with_dbs/03/workspaces/test_workspaces__folders_and_projects_crud.py b/services/web/server/tests/unit/with_dbs/03/workspaces/test_workspaces__folders_and_projects_crud.py new file mode 100644 index 00000000000..545f28d1b58 --- /dev/null +++ b/services/web/server/tests/unit/with_dbs/03/workspaces/test_workspaces__folders_and_projects_crud.py @@ -0,0 +1,227 @@ +# pylint: disable=redefined-outer-name +# pylint: disable=unused-argument +# pylint: disable=unused-variable +# pylint: disable=too-many-arguments +# pylint: disable=too-many-statements + + +from copy import deepcopy +from http import HTTPStatus + +import pytest +from aiohttp.test_utils import TestClient +from pytest_mock import MockerFixture +from pytest_simcore.helpers.assert_checks import assert_status +from pytest_simcore.helpers.webserver_login import LoggedUser, UserInfoDict +from pytest_simcore.helpers.webserver_projects import create_project +from pytest_simcore.helpers.webserver_workspaces import update_or_insert_workspace_group +from servicelib.aiohttp import status +from simcore_service_webserver.db.models import UserRole +from simcore_service_webserver.projects.models import ProjectDict + + +@pytest.fixture +def mock_catalog_api_get_services_for_user_in_product(mocker: MockerFixture): + mocker.patch( + "simcore_service_webserver.projects._crud_api_read.get_services_for_user_in_product", + spec=True, + return_value=[], + ) + mocker.patch( + "simcore_service_webserver.projects._crud_handlers.get_services_for_user_in_product", + spec=True, + return_value=[], + ) + mocker.patch( + "simcore_service_webserver.projects._crud_handlers.project_uses_available_services", + spec=True, + return_value=True, + ) + + +@pytest.mark.parametrize("user_role,expected", [(UserRole.USER, status.HTTP_200_OK)]) +async def test_workspaces_full_workflow_with_folders_and_projects( + 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, +): + assert client.app + + # create a new workspace + url = client.app.router["create_workspace"].url_for() + resp = await client.post( + url.path, + json={ + "name": "My first workspace", + "description": "Custom description", + "thumbnail": None, + }, + ) + added_workspace, _ = await assert_status(resp, status.HTTP_201_CREATED) + + # Create project in workspace + project_data = deepcopy(fake_project) + project_data["workspace_id"] = f"{added_workspace['workspaceId']}" + project = await create_project( + client.app, + project_data, + user_id=logged_user["id"], + product_name="osparc", + ) + + # List project in workspace + base_url = client.app.router["list_projects"].url_for() + url = base_url.with_query({"workspace_id": f"{added_workspace['workspaceId']}"}) + resp = await client.get(url) + data, _ = await assert_status(resp, status.HTTP_200_OK) + assert len(data) == 1 + assert data[0]["uuid"] == project["uuid"] + + # Get project in workspace + base_url = client.app.router["get_project"].url_for(project_id=project["uuid"]) + resp = await client.get(base_url) + data, _ = await assert_status(resp, status.HTTP_200_OK) + assert data["uuid"] == project["uuid"] + + # Create folder in workspace + url = client.app.router["create_folder"].url_for() + resp = await client.post( + url.path, + json={ + "name": "Original user folder", + "workspaceId": f"{added_workspace['workspaceId']}", + }, + ) + first_folder, _ = await assert_status(resp, status.HTTP_201_CREATED) + + # List folders in workspace + base_url = client.app.router["list_folders"].url_for() + url = base_url.with_query( + {"workspace_id": f"{added_workspace['workspaceId']}", "folder_id": "null"} + ) + resp = await client.get(url) + data, _ = await assert_status(resp, status.HTTP_200_OK) + assert len(data) == 1 + assert data[0]["folderId"] == first_folder["folderId"] + + # Move project in specific folder in workspace + url = client.app.router["replace_project_folder"].url_for( + folder_id=f"{first_folder['folderId']}", + project_id=f"{project['uuid']}", + ) + resp = await client.put(url.path) + await assert_status(resp, status.HTTP_204_NO_CONTENT) + + # List projects in specific folder in workspace + base_url = client.app.router["list_projects"].url_for() + url = base_url.with_query( + { + "workspace_id": f"{added_workspace['workspaceId']}", + "folder_id": f"{first_folder['folderId']}", + } + ) + resp = await client.get(url) + data, _ = await assert_status(resp, status.HTTP_200_OK) + assert len(data) == 1 + assert data[0]["uuid"] == project["uuid"] + + # Create new user + async with LoggedUser(client) as new_logged_user: + # Try to list folder that user doesn't have access to + base_url = client.app.router["list_projects"].url_for() + url = base_url.with_query({"workspace_id": f"{added_workspace['workspaceId']}"}) + resp = await client.get(url) + _, errors = await assert_status( + resp, + status.HTTP_401_UNAUTHORIZED, + ) + assert errors + + # Now we will share the workspace with the new user + await update_or_insert_workspace_group( + client.app, + workspace_id=added_workspace["workspaceId"], + group_id=new_logged_user["primary_gid"], + read=True, + write=True, + delete=False, + ) + + # New user list root folders inside of workspace + base_url = client.app.router["list_folders"].url_for() + url = base_url.with_query( + {"workspace_id": f"{added_workspace['workspaceId']}", "folder_id": "null"} + ) + resp = await client.get(url) + data, _ = await assert_status(resp, status.HTTP_200_OK) + assert len(data) == 1 + + # New user list root projects inside of workspace + base_url = client.app.router["list_projects"].url_for() + url = base_url.with_query( + { + "workspace_id": f"{added_workspace['workspaceId']}", + "folder_id": "none", + } + ) + resp = await client.get(url) + data, _ = await assert_status(resp, status.HTTP_200_OK) + assert len(data) == 0 + + # New user list projects in specific folder inside of workspace + base_url = client.app.router["list_projects"].url_for() + url = base_url.with_query( + { + "workspace_id": f"{added_workspace['workspaceId']}", + "folder_id": f"{first_folder['folderId']}", + } + ) + resp = await client.get(url) + data, _ = await assert_status(resp, status.HTTP_200_OK) + assert len(data) == 1 + assert data[0]["uuid"] == project["uuid"] + + # New user with write permission creates a folder + url = client.app.router["create_folder"].url_for() + resp = await client.post( + url.path, + json={ + "name": "New user folder", + "workspaceId": f"{added_workspace['workspaceId']}", + }, + ) + await assert_status(resp, status.HTTP_201_CREATED) + + # Now we will remove write permissions + await update_or_insert_workspace_group( + client.app, + workspace_id=added_workspace["workspaceId"], + group_id=new_logged_user["primary_gid"], + read=True, + write=False, + delete=False, + ) + + # Now error is raised on the creation of folder as user doesn't have write access + url = client.app.router["create_folder"].url_for() + resp = await client.post( + url.path, + json={ + "name": "New user second folder", + "workspaceId": f"{added_workspace['workspaceId']}", + }, + ) + await assert_status(resp, status.HTTP_403_FORBIDDEN) + + # But user has still read permissions + base_url = client.app.router["list_folders"].url_for() + url = base_url.with_query( + {"workspace_id": f"{added_workspace['workspaceId']}", "folder_id": "null"} + ) + resp = await client.get(url) + data, _ = await assert_status(resp, status.HTTP_200_OK) + assert len(data) == 2 diff --git a/services/web/server/tests/unit/with_dbs/03/workspaces/test_workspaces_groups.py b/services/web/server/tests/unit/with_dbs/03/workspaces/test_workspaces_groups.py new file mode 100644 index 00000000000..3bb41d3bd37 --- /dev/null +++ b/services/web/server/tests/unit/with_dbs/03/workspaces/test_workspaces_groups.py @@ -0,0 +1,113 @@ +# pylint: disable=redefined-outer-name +# pylint: disable=unused-argument +# pylint: disable=unused-variable +# pylint: disable=too-many-arguments + + +from collections.abc import AsyncIterator +from http import HTTPStatus + +import pytest +from aiohttp.test_utils import TestClient +from pytest_simcore.helpers.assert_checks import assert_status +from pytest_simcore.helpers.webserver_login import NewUser, UserInfoDict +from servicelib.aiohttp import status +from simcore_service_webserver.db.models import UserRole +from simcore_service_webserver.projects.models import ProjectDict + + +@pytest.mark.parametrize("user_role,expected", [(UserRole.USER, status.HTTP_200_OK)]) +async def test_workspaces_groups_full_workflow( + client: TestClient, + logged_user: UserInfoDict, + user_project: ProjectDict, + expected: HTTPStatus, + workspaces_clean_db: AsyncIterator[None], +): + # create a new workspace + url = client.app.router["create_workspace"].url_for() + resp = await client.post( + f"{url}", + json={"name": "My first workspace", "description": "Custom description"}, + ) + added_workspace, _ = await assert_status(resp, status.HTTP_201_CREATED) + + # check the default workspace permissions + url = client.app.router["list_workspace_groups"].url_for( + workspace_id=f"{added_workspace['workspaceId']}" + ) + resp = await client.get(f"{url}") + 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 + + async with NewUser( + app=client.app, + ) as new_user: + # We add new user to the workspace + url = client.app.router["create_workspace_group"].url_for( + workspace_id=f"{added_workspace['workspaceId']}", + 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) + + # Check the workspace permissions of added user + url = client.app.router["list_workspace_groups"].url_for( + workspace_id=f"{added_workspace['workspaceId']}" + ) + 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"] == True + assert data[1]["write"] == False + assert data[1]["delete"] == False + + # Update the workspace permissions of the added user + url = client.app.router["replace_workspace_group"].url_for( + workspace_id=f"{added_workspace['workspaceId']}", + 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) + assert data["gid"] == new_user["primary_gid"] + assert data["read"] == True + assert data["write"] == True + assert data["delete"] == False + + # List the workspace groups + url = client.app.router["list_workspace_groups"].url_for( + workspace_id=f"{added_workspace['workspaceId']}" + ) + 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"] == True + assert data[1]["write"] == True + assert data[1]["delete"] == False + + # Delete the workspace group + url = client.app.router["delete_workspace_group"].url_for( + workspace_id=f"{added_workspace['workspaceId']}", + group_id=f"{new_user['primary_gid']}", + ) + resp = await client.delete(f"{url}") + await assert_status(resp, status.HTTP_204_NO_CONTENT) + + # List the workspace groups + url = client.app.router["list_workspace_groups"].url_for( + workspace_id=f"{added_workspace['workspaceId']}" + ) + resp = await client.get(f"{url}") + data, _ = await assert_status(resp, status.HTTP_200_OK) + assert len(data) == 1 + assert data[0]["gid"] == logged_user["primary_gid"] diff --git a/tests/e2e/tutorials/sleepers_project_template_sql.csv b/tests/e2e/tutorials/sleepers_project_template_sql.csv index 36dcd32be8f..46634511eec 100644 --- a/tests/e2e/tutorials/sleepers_project_template_sql.csv +++ b/tests/e2e/tutorials/sleepers_project_template_sql.csv @@ -1,2 +1,2 @@ -id,type,uuid,name,description,thumbnail,prj_owner,creation_date,last_change_date,workbench,published,access_rights,dev,ui,classifiers,quality,hidden -10,TEMPLATE,ed6c2f58-dc16-445d-bb97-e989e2611603,Sleepers,5 sleepers interconnected,"",,2019-06-06 14:34:19.631,2019-06-06 14:34:28.647,"{""027e3ff9-3119-45dd-b8a2-2e31661a7385"": {""key"": ""simcore/services/comp/itis/sleeper"", ""version"": ""1.0.0"", ""label"": ""sleeper 0"", ""inputs"": {""in_2"": 2}, ""inputAccess"": {""in_1"": ""Invisible"", ""in_2"": ""ReadOnly""}, ""inputNodes"": [], ""outputs"": {}, ""progress"": 0, ""thumbnail"": """", ""position"": {""x"": 50, ""y"": 300}}, ""562aaea9-95ff-46f3-8e84-db8f3c9e3a39"": {""key"": ""simcore/services/comp/itis/sleeper"", ""version"": ""1.0.0"", ""label"": ""sleeper 1"", ""inputs"": {""in_1"": {""nodeUuid"": ""027e3ff9-3119-45dd-b8a2-2e31661a7385"", ""output"": ""out_1""}, ""in_2"": 2}, ""inputNodes"": [""027e3ff9-3119-45dd-b8a2-2e31661a7385""], ""outputs"": {}, ""progress"": 0, ""thumbnail"": """", ""position"": {""x"": 300, ""y"": 200}}, ""bf405067-d168-44ba-b6dc-bb3e08542f92"": {""key"": ""simcore/services/comp/itis/sleeper"", ""version"": ""1.0.0"", ""label"": ""sleeper 2"", ""inputs"": {""in_1"": {""nodeUuid"": ""562aaea9-95ff-46f3-8e84-db8f3c9e3a39"", ""output"": ""out_1""}, ""in_2"": {""nodeUuid"": ""562aaea9-95ff-46f3-8e84-db8f3c9e3a39"", ""output"": ""out_2""}}, ""inputNodes"": [""562aaea9-95ff-46f3-8e84-db8f3c9e3a39""], ""outputs"": {}, ""progress"": 0, ""thumbnail"": """", ""position"": {""x"": 550, ""y"": 200}}, ""de2578c5-431e-5065-a079-a5a0476e3c10"": {""key"": ""simcore/services/comp/itis/sleeper"", ""version"": ""1.0.0"", ""label"": ""sleeper 3"", ""inputs"": {""in_2"": {""nodeUuid"": ""027e3ff9-3119-45dd-b8a2-2e31661a7385"", ""output"": ""out_2""}}, ""inputNodes"": [""027e3ff9-3119-45dd-b8a2-2e31661a7385""], ""outputs"": {}, ""progress"": 0, ""thumbnail"": """", ""position"": {""x"": 420, ""y"": 400}}, ""de2578c5-431e-559d-aa19-dc9293e10e4c"": {""key"": ""simcore/services/comp/itis/sleeper"", ""version"": ""1.0.0"", ""label"": ""sleeper 4"", ""inputs"": {""in_1"": {""nodeUuid"": ""bf405067-d168-44ba-b6dc-bb3e08542f92"", ""output"": ""out_1""}, ""in_2"": {""nodeUuid"": ""de2578c5-431e-5065-a079-a5a0476e3c10"", ""output"": ""out_2""}}, ""inputNodes"": [""bf405067-d168-44ba-b6dc-bb3e08542f92"", ""de2578c5-431e-5065-a079-a5a0476e3c10""], ""outputs"": {}, ""progress"": 0, ""thumbnail"": """", ""position"": {""x"": 800, ""y"": 300}}}",true,"{""1"": {""read"":true, ""write"":false, ""delete"":false}}", "{}", "{}", "{}", "{}",false +id,type,uuid,name,description,thumbnail,prj_owner,creation_date,last_change_date,workbench,published,access_rights,dev,ui,classifiers,quality,hidden,workspace_id +10,TEMPLATE,ed6c2f58-dc16-445d-bb97-e989e2611603,Sleepers,5 sleepers interconnected,"",,2019-06-06 14:34:19.631,2019-06-06 14:34:28.647,"{""027e3ff9-3119-45dd-b8a2-2e31661a7385"": {""key"": ""simcore/services/comp/itis/sleeper"", ""version"": ""1.0.0"", ""label"": ""sleeper 0"", ""inputs"": {""in_2"": 2}, ""inputAccess"": {""in_1"": ""Invisible"", ""in_2"": ""ReadOnly""}, ""inputNodes"": [], ""outputs"": {}, ""progress"": 0, ""thumbnail"": """", ""position"": {""x"": 50, ""y"": 300}}, ""562aaea9-95ff-46f3-8e84-db8f3c9e3a39"": {""key"": ""simcore/services/comp/itis/sleeper"", ""version"": ""1.0.0"", ""label"": ""sleeper 1"", ""inputs"": {""in_1"": {""nodeUuid"": ""027e3ff9-3119-45dd-b8a2-2e31661a7385"", ""output"": ""out_1""}, ""in_2"": 2}, ""inputNodes"": [""027e3ff9-3119-45dd-b8a2-2e31661a7385""], ""outputs"": {}, ""progress"": 0, ""thumbnail"": """", ""position"": {""x"": 300, ""y"": 200}}, ""bf405067-d168-44ba-b6dc-bb3e08542f92"": {""key"": ""simcore/services/comp/itis/sleeper"", ""version"": ""1.0.0"", ""label"": ""sleeper 2"", ""inputs"": {""in_1"": {""nodeUuid"": ""562aaea9-95ff-46f3-8e84-db8f3c9e3a39"", ""output"": ""out_1""}, ""in_2"": {""nodeUuid"": ""562aaea9-95ff-46f3-8e84-db8f3c9e3a39"", ""output"": ""out_2""}}, ""inputNodes"": [""562aaea9-95ff-46f3-8e84-db8f3c9e3a39""], ""outputs"": {}, ""progress"": 0, ""thumbnail"": """", ""position"": {""x"": 550, ""y"": 200}}, ""de2578c5-431e-5065-a079-a5a0476e3c10"": {""key"": ""simcore/services/comp/itis/sleeper"", ""version"": ""1.0.0"", ""label"": ""sleeper 3"", ""inputs"": {""in_2"": {""nodeUuid"": ""027e3ff9-3119-45dd-b8a2-2e31661a7385"", ""output"": ""out_2""}}, ""inputNodes"": [""027e3ff9-3119-45dd-b8a2-2e31661a7385""], ""outputs"": {}, ""progress"": 0, ""thumbnail"": """", ""position"": {""x"": 420, ""y"": 400}}, ""de2578c5-431e-559d-aa19-dc9293e10e4c"": {""key"": ""simcore/services/comp/itis/sleeper"", ""version"": ""1.0.0"", ""label"": ""sleeper 4"", ""inputs"": {""in_1"": {""nodeUuid"": ""bf405067-d168-44ba-b6dc-bb3e08542f92"", ""output"": ""out_1""}, ""in_2"": {""nodeUuid"": ""de2578c5-431e-5065-a079-a5a0476e3c10"", ""output"": ""out_2""}}, ""inputNodes"": [""bf405067-d168-44ba-b6dc-bb3e08542f92"", ""de2578c5-431e-5065-a079-a5a0476e3c10""], ""outputs"": {}, ""progress"": 0, ""thumbnail"": """", ""position"": {""x"": 800, ""y"": 300}}}",true,"{""1"": {""read"":true, ""write"":false, ""delete"":false}}", "{}", "{}", "{}", "{}",false,