Skip to content

Commit 35fa716

Browse files
authored
✨ Error pages when published studies dispatcher fails, access rights fixes and plugin refactoring (ITISFoundation#3962)
1 parent 77c36e0 commit 35fa716

26 files changed

+673
-365
lines changed

.vscode/launch.template.json

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,19 @@
107107
}
108108
]
109109
},
110+
{
111+
"name": "Python: Remote Attach webserver-garbage-collector",
112+
"type": "python",
113+
"request": "attach",
114+
"port": 3011,
115+
"host": "127.0.0.1",
116+
"pathMappings": [
117+
{
118+
"localRoot": "${workspaceFolder}",
119+
"remoteRoot": "/devel"
120+
}
121+
]
122+
},
110123
{
111124
"name": "Python: Remote Attach storage",
112125
"type": "python",

scripts/docker/docker-compose-config.bash

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -89,7 +89,7 @@ docker-compose \
8989
--env-file ${env_file}"
9090
for compose_file_path in "$@"
9191
do
92-
docker_command+=" --file=${compose_file_path}"
92+
docker_command+=" --file=${compose_file_path} "
9393
done
9494
docker_command+=" \
9595
config \

services/storage/src/simcore_service_storage/exceptions.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,12 +17,14 @@ class FileMetaDataNotFoundError(DatabaseAccessError):
1717

1818
class FileAccessRightError(DatabaseAccessError):
1919
code = "file.access_right_error"
20-
msg_template: str = "Insufficient access rights to {access_right} {file_id}"
20+
msg_template: str = "Insufficient access rights to {access_right} data {file_id}"
2121

2222

2323
class ProjectAccessRightError(DatabaseAccessError):
2424
code = "file.access_right_error"
25-
msg_template: str = "Insufficient access rights to {access_right} {project_id}"
25+
msg_template: str = (
26+
"Insufficient access rights to {access_right} project {project_id}"
27+
)
2628

2729

2830
class ProjectNotFoundError(DatabaseAccessError):

services/web/server/src/simcore_service_webserver/login/handlers_registration.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -150,7 +150,7 @@ async def register(request: web.Request):
150150

151151
await check_other_registrations(email=registration.email, db=db, cfg=cfg)
152152

153-
expires_at = None # = does not expire
153+
expires_at: Optional[datetime] = None # = does not expire
154154
if settings.LOGIN_REGISTRATION_INVITATION_REQUIRED:
155155
# Only requests with INVITATION can register user
156156
# to either a permanent or to a trial account

services/web/server/src/simcore_service_webserver/projects/projects_db.py

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -35,8 +35,9 @@
3535
from ..utils import now_str
3636
from .project_models import ProjectDict
3737
from .projects_db_utils import (
38+
ANY_USER_ID_SENTINEL,
3839
BaseProjectDB,
39-
Permission,
40+
PermissionStr,
4041
ProjectAccessRights,
4142
assemble_array_groups,
4243
check_project_permissions,
@@ -55,7 +56,7 @@
5556
log = logging.getLogger(__name__)
5657

5758
APP_PROJECT_DBAPI = __name__ + ".ProjectDBAPI"
58-
59+
ANY_USER = ANY_USER_ID_SENTINEL
5960

6061
# pylint: disable=too-many-public-methods
6162
# NOTE: https://github.com/ITISFoundation/osparc-simcore/issues/3516
@@ -316,7 +317,7 @@ async def get_project(
316317
*,
317318
only_published: bool = False,
318319
only_templates: bool = False,
319-
check_permissions: Permission = "read",
320+
check_permissions: PermissionStr = "read",
320321
) -> tuple[ProjectDict, ProjectType]:
321322
"""Returns all projects *owned* by the user
322323
@@ -325,6 +326,7 @@ async def get_project(
325326
- Notice that a user can have access to a project where he/she has read access
326327
327328
:raises ProjectNotFoundError: project is not assigned to user
329+
raises ProjectInvalidRightsError: if user has no access rights to do check_permissions
328330
"""
329331
async with self.engine.acquire() as conn:
330332
project = await self._get_project(
@@ -634,7 +636,7 @@ async def list_node_ids_in_project(self, project_uuid: str) -> set[str]:
634636
#
635637

636638
async def has_permission(
637-
self, user_id: UserID, project_uuid: str, permission: Permission
639+
self, user_id: UserID, project_uuid: str, permission: PermissionStr
638640
) -> bool:
639641
"""
640642
NOTE: this function should never raise

services/web/server/src/simcore_service_webserver/projects/projects_db_utils.py

Lines changed: 86 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
""" Database utisl
1+
""" Database utils
22
33
"""
44
import asyncio
@@ -32,7 +32,9 @@
3232
DB_EXCLUSIVE_COLUMNS = ["type", "id", "published", "hidden"]
3333
SCHEMA_NON_NULL_KEYS = ["thumbnail"]
3434

35-
Permission = Literal["read", "write", "delete"]
35+
PermissionStr = Literal["read", "write", "delete"]
36+
37+
ANY_USER_ID_SENTINEL = -1
3638

3739

3840
class ProjectAccessRights(Enum):
@@ -46,51 +48,75 @@ def check_project_permissions(
4648
project: Union[ProjectProxy, ProjectDict],
4749
user_id: int,
4850
user_groups: list[RowProxy],
49-
permission: Permission,
51+
permission: str,
5052
) -> None:
53+
"""
54+
:raises ProjectInvalidRightsError if check fails
55+
"""
56+
5157
if not permission:
5258
return
5359

54-
needed_permissions = permission.split("|")
60+
operations_on_project = set(permission.split("|"))
61+
assert set(operations_on_project).issubset(set(PermissionStr.__args__)) # nosec
5562

56-
# compute access rights by order of priority all group > organizations > primary
57-
primary_group = next(
58-
filter(lambda x: x.get("type") == GroupType.PRIMARY, user_groups), None
59-
)
60-
standard_groups = filter(lambda x: x.get("type") == GroupType.STANDARD, user_groups)
63+
#
64+
# Get primary_gid, standard_gids and everyone_gid for user_id
65+
#
6166
all_group = next(
6267
filter(lambda x: x.get("type") == GroupType.EVERYONE, user_groups), None
6368
)
64-
if primary_group is None or all_group is None:
65-
# the user groups is missing entries
69+
if all_group is None:
6670
raise ProjectInvalidRightsError(user_id, project.get("uuid"))
6771

72+
everyone_gid = str(all_group["gid"])
73+
74+
if user_id == ANY_USER_ID_SENTINEL:
75+
primary_gid = None
76+
standard_gids = []
77+
78+
else:
79+
primary_group = next(
80+
filter(lambda x: x.get("type") == GroupType.PRIMARY, user_groups), None
81+
)
82+
if primary_group is None:
83+
# the user groups is missing entries
84+
raise ProjectInvalidRightsError(user_id, project.get("uuid"))
85+
86+
standard_groups = filter(
87+
lambda x: x.get("type") == GroupType.STANDARD, user_groups
88+
)
89+
90+
primary_gid = str(primary_group["gid"])
91+
standard_gids = [str(group["gid"]) for group in standard_groups]
92+
93+
#
94+
# Composes access rights by order of priority all group > organizations > primary
95+
#
6896
project_access_rights = deepcopy(project.get("access_rights", {}))
6997

70-
# compute access rights
71-
no_access_rights = {"read": False, "write": False, "delete": False}
72-
computed_permissions = project_access_rights.get(
73-
str(all_group["gid"]), no_access_rights
98+
# access rights for everyone
99+
user_can = project_access_rights.get(
100+
everyone_gid, {"read": False, "write": False, "delete": False}
74101
)
75102

76-
# get the standard groups
77-
for group in standard_groups:
103+
# access rights for standard groups
104+
for group_id in standard_gids:
78105
standard_project_access = project_access_rights.get(
79-
str(group["gid"]), no_access_rights
106+
group_id, {"read": False, "write": False, "delete": False}
80107
)
81-
for k in computed_permissions.keys():
82-
computed_permissions[k] = (
83-
computed_permissions[k] or standard_project_access[k]
108+
for operation in user_can.keys():
109+
user_can[operation] = (
110+
user_can[operation] or standard_project_access[operation]
84111
)
85-
86-
# get the primary group access
112+
# access rights for primary group
87113
primary_access_right = project_access_rights.get(
88-
str(primary_group["gid"]), no_access_rights
114+
primary_gid, {"read": False, "write": False, "delete": False}
89115
)
90-
for k in computed_permissions.keys():
91-
computed_permissions[k] = computed_permissions[k] or primary_access_right[k]
116+
for operation in user_can.keys():
117+
user_can[operation] = user_can[operation] or primary_access_right[operation]
92118

93-
if any(not computed_permissions[p] for p in needed_permissions):
119+
if any(not user_can[operation] for operation in operations_on_project):
94120
raise ProjectInvalidRightsError(user_id, project.get("uuid"))
95121

96122

@@ -146,16 +172,31 @@ def assemble_array_groups(user_groups: list[RowProxy]) -> str:
146172

147173

148174
class BaseProjectDB:
149-
@staticmethod
150-
async def _list_user_groups(conn: SAConnection, user_id: int) -> list[RowProxy]:
151-
user_groups: list[RowProxy] = []
152-
query = (
153-
select([groups])
154-
.select_from(groups.join(user_to_groups))
155-
.where(user_to_groups.c.uid == user_id)
175+
@classmethod
176+
async def _get_everyone_group(cls, conn: SAConnection) -> RowProxy:
177+
result = await conn.execute(
178+
sa.select([groups]).where(groups.c.type == GroupType.EVERYONE)
156179
)
157-
async for row in conn.execute(query):
158-
user_groups.append(row)
180+
row = await result.first()
181+
return row
182+
183+
@classmethod
184+
async def _list_user_groups(
185+
cls, conn: SAConnection, user_id: int
186+
) -> list[RowProxy]:
187+
user_groups: list[RowProxy] = []
188+
189+
if user_id == ANY_USER_ID_SENTINEL:
190+
everyone_group = await cls._get_everyone_group(conn)
191+
assert everyone_group # nosec
192+
user_groups.append(everyone_group)
193+
else:
194+
result = await conn.execute(
195+
select([groups])
196+
.select_from(groups.join(user_to_groups))
197+
.where(user_to_groups.c.uid == user_id)
198+
)
199+
user_groups = await result.fetchall()
159200
return user_groups
160201

161202
@staticmethod
@@ -268,10 +309,11 @@ async def _get_project(
268309
for_update: bool = False,
269310
only_templates: bool = False,
270311
only_published: bool = False,
271-
check_permissions: Permission = "read",
312+
check_permissions: PermissionStr = "read",
272313
) -> dict:
273314
"""
274-
raises: ProjectNotFoundError
315+
raises ProjectNotFoundError if project does not exists
316+
raises ProjectInvalidRightsError if user_id does not have at 'check_permissions' access rights
275317
"""
276318
exclude_foreign = exclude_foreign or []
277319

@@ -308,14 +350,14 @@ async def _get_project(
308350
project_row = await result.first()
309351

310352
if not project_row:
311-
raise ProjectNotFoundError(project_uuid)
312-
313-
# now carefuly check the access rights
314-
if only_published is False:
315-
check_project_permissions(
316-
project_row, user_id, user_groups, check_permissions
353+
raise ProjectNotFoundError(
354+
project_uuid=project_uuid,
355+
search_context=f"{user_id=}, {only_templates=}, {only_published=}, {check_permissions=}",
317356
)
318357

358+
# check the access rights
359+
check_project_permissions(project_row, user_id, user_groups, check_permissions)
360+
319361
project: dict[str, Any] = dict(project_row.items())
320362

321363
if "tags" not in exclude_foreign:

services/web/server/src/simcore_service_webserver/projects/projects_exceptions.py

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
"""Defines the different exceptions that may arise in the projects subpackage"""
2+
from typing import Any, Optional
3+
24
import redis.exceptions
35
from models_library.projects import ProjectID
46
from models_library.users import UserID
@@ -10,6 +12,10 @@ class ProjectsException(Exception):
1012
def __init__(self, msg=None):
1113
super().__init__(msg or "Unexpected error occured in projects submodule")
1214

15+
def detailed_message(self):
16+
# Override in subclass
17+
return f"{type(self)}: {self}"
18+
1319

1420
class ProjectInvalidRightsError(ProjectsException):
1521
"""Invalid rights to access project"""
@@ -33,9 +39,17 @@ def __init__(self, project_uuid):
3339
class ProjectNotFoundError(ProjectsException):
3440
"""Project was not found in DB"""
3541

36-
def __init__(self, project_uuid):
37-
super().__init__(f"Project with uuid {project_uuid} not found")
42+
def __init__(self, project_uuid, *, search_context: Optional[Any] = None):
43+
super().__init__(f"Project with uuid {project_uuid} not found.")
3844
self.project_uuid = project_uuid
45+
self.search_context_msg = f"{search_context}"
46+
47+
def detailed_message(self):
48+
msg = f"Project with uuid {self.project_uuid}"
49+
if self.search_context_msg:
50+
msg += f" and {self.search_context_msg}"
51+
msg += " was not found"
52+
return msg
3953

4054

4155
class ProjectDeleteError(ProjectsException):
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
from typing import Final
2+
3+
# NOTE: MSG_* strings MUST be human readable messages
4+
5+
MSG_PROJECT_NOT_FOUND: Final[str] = "Cannot find any study with ID '{project_id}'."
6+
7+
8+
MSG_PROJECT_NOT_PUBLISHED: Final[
9+
str
10+
] = "Cannot find any published study with ID '{project_id}'"
11+
12+
13+
MSG_UNEXPECTED_ERROR: Final[
14+
str
15+
] = "Opps this is embarrasing! Something went really wrong {hint}"

services/web/server/src/simcore_service_webserver/studies_dispatcher/_projects.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -159,7 +159,7 @@ async def add_new_project(
159159
app: web.Application, project: Project, user: UserInfo, *, product_name: str
160160
):
161161
# TODO: move this to projects_api
162-
# TODO: this piece was taking fromt the end of projects.projects_handlers.create_projects
162+
# TODO: this piece was taken from the end of projects.projects_handlers.create_projects
163163

164164
from ..director_v2_api import create_or_update_pipeline
165165
from ..projects.projects_db import APP_PROJECT_DBAPI

0 commit comments

Comments
 (0)