diff --git a/components/renku_data_services/authz/schemas.py b/components/renku_data_services/authz/schemas.py index 5556911f4..6d934c2c9 100644 --- a/components/renku_data_services/authz/schemas.py +++ b/components/renku_data_services/authz/schemas.py @@ -489,8 +489,8 @@ def generate_v4(public_project_ids: Iterable[str]) -> AuthzSchemaMigration: relation editor: user relation viewer: user relation public_viewer: user:* | anonymous_user:* - permission read = read_children - permission read_children = public_viewer + viewer + write + project_namespace->read_children + permission read = public_viewer + read_children + permission read_children = viewer + write + project_namespace->read_children permission write = editor + delete + project_namespace->write permission change_membership = delete permission delete = owner + project_platform->is_admin + project_namespace->delete diff --git a/components/renku_data_services/data_connectors/db.py b/components/renku_data_services/data_connectors/db.py index 701047975..c267566fb 100644 --- a/components/renku_data_services/data_connectors/db.py +++ b/components/renku_data_services/data_connectors/db.py @@ -499,9 +499,13 @@ async def get_links_to( message=f"Project with id '{project_id}' does not exist or you do not have access to it." ) + allowed_dcs = await self.authz.resources_with_permission(user, user.id, ResourceType.data_connector, Scope.READ) + async with self.session_maker() as session: - stmt = select(schemas.DataConnectorToProjectLinkORM).where( - schemas.DataConnectorToProjectLinkORM.project_id == project_id + stmt = ( + select(schemas.DataConnectorToProjectLinkORM) + .where(schemas.DataConnectorToProjectLinkORM.project_id == project_id) + .where(schemas.DataConnectorToProjectLinkORM.data_connector_id.in_(allowed_dcs)) ) result = await session.scalars(stmt) links_orm = result.all() @@ -586,6 +590,21 @@ async def copy_link( session: AsyncSession | None = None, ) -> models.DataConnectorToProjectLink: """Create a new link from a given data connector link to a project.""" + allowed_to_read_dc = await self.authz.has_permission( + user, ResourceType.data_connector, link.data_connector_id, Scope.READ + ) + allowed_to_write_project = await self.authz.has_permission( + user, ResourceType.project, link.project_id, Scope.WRITE + ) + if not allowed_to_read_dc: + raise errors.MissingResourceError( + message=f"The data connector with ID {link.data_connector_id} does not exist " + "or you do not have access to it" + ) + if not allowed_to_write_project: + raise errors.MissingResourceError( + message=f"The project with ID {link.project_id} does not exist or you do not have access to it" + ) unsaved_link = models.UnsavedDataConnectorToProjectLink( data_connector_id=link.data_connector_id, project_id=project_id ) @@ -615,6 +634,14 @@ async def delete_link( if link_orm is None: return None + allowed_to_write_project = await self.authz.has_permission( + user, ResourceType.project, link_orm.project_id, Scope.WRITE + ) + if not allowed_to_write_project: + raise errors.MissingResourceError( + message=f"The project with ID {link_orm.project_id} does not exist or you do not have access to it" + ) + link = link_orm.dump() await session.delete(link_orm) return link diff --git a/test/bases/renku_data_services/data_api/test_data_connectors.py b/test/bases/renku_data_services/data_api/test_data_connectors.py index 5385e85d8..3c4ef102f 100644 --- a/test/bases/renku_data_services/data_api/test_data_connectors.py +++ b/test/bases/renku_data_services/data_api/test_data_connectors.py @@ -1366,7 +1366,7 @@ async def test_creating_dc_in_project(sanic_client, user_headers) -> None: dc_namespace = "test1/prj1" payload = { "name": "dc1", - "namespace": "test1/prj1", + "namespace": dc_namespace, "slug": "dc1", "storage": { "configuration": {"type": "s3", "endpoint": "http://s3.aws.com"}, @@ -1408,3 +1408,146 @@ async def test_creating_dc_in_project(sanic_client, user_headers) -> None: assert response.status_code == 200, response.text assert len(response.json) == 1 assert response.json[0]["namespace"] == dc_namespace + + +@pytest.mark.asyncio +async def test_users_cannot_see_private_data_connectors_in_project( + sanic_client, + member_1_headers, + member_2_user: UserInfo, + member_2_headers, + user_headers, + regular_user: UserInfo, +) -> None: + # Create a group i.e. /test1 + group_slug = "test1" + payload = { + "name": group_slug, + "slug": group_slug, + "description": "Group 1 Description", + } + _, response = await sanic_client.post("/api/data/groups", headers=member_1_headers, json=payload) + assert response.status_code == 201, response.text + + # Add member_2 as reader on the group + payload = [ + { + "id": member_2_user.id, + "role": "viewer", + } + ] + _, response = await sanic_client.patch( + f"/api/data/groups/{group_slug}/members", headers=member_1_headers, json=payload + ) + assert response.status_code == 200, response.text + + # Create a public project in the group /test1/prj1 + payload = { + "name": "prj1", + "namespace": "test1", + "slug": "prj1", + "visibility": "public", + } + _, response = await sanic_client.post("/api/data/projects", headers=member_1_headers, json=payload) + assert response.status_code == 201, response.text + project_id = response.json["id"] + + # Create a private data connector in the group + dc_namespace = "test1" + storage_config = { + "configuration": {"type": "s3", "endpoint": "http://s3.aws.com"}, + "source_path": "giab", + "target_path": "giab", + } + payload = { + "name": "dc-private", + "namespace": dc_namespace, + "slug": "dc-private", + "storage": storage_config, + "visibility": "private", + } + _, response = await sanic_client.post("/api/data/data_connectors", headers=member_1_headers, json=payload) + assert response.status_code == 201, response.text + assert response.json["namespace"] == dc_namespace + group_dc_id = response.json["id"] + + # Link the private data connector to the project + payload = {"project_id": project_id} + _, response = await sanic_client.post( + f"/api/data/data_connectors/{group_dc_id}/project_links", headers=member_1_headers, json=payload + ) + assert response.status_code == 201, response.text + + # Create a data connector in the project /test1/proj1/dc1 + dc_namespace = "test1/prj1" + payload = { + "name": "dc1", + "namespace": dc_namespace, + "slug": "dc1", + "storage": storage_config, + } + _, response = await sanic_client.post("/api/data/data_connectors", headers=member_1_headers, json=payload) + assert response.status_code == 201, response.text + assert response.json["namespace"] == dc_namespace + project_dc_id = response.json["id"] + + # Link the data connector to the project + payload = {"project_id": project_id} + _, response = await sanic_client.post( + f"/api/data/data_connectors/{project_dc_id}/project_links", headers=member_1_headers, json=payload + ) + assert response.status_code == 201, response.text + + # Ensure that member_1 and member_2 can see both data connectors and their links + for req_headers in [member_1_headers, member_2_headers]: + _, response = await sanic_client.get("/api/data/data_connectors", headers=req_headers) + assert response.status_code == 200, response.text + assert len(response.json) == 2 + assert response.json[0]["id"] == project_dc_id + assert response.json[1]["id"] == group_dc_id + _, response = await sanic_client.get( + f"/api/data/projects/{project_id}/data_connector_links", headers=req_headers + ) + assert len(response.json) == 2 + assert response.json[0]["data_connector_id"] == group_dc_id + assert response.json[1]["data_connector_id"] == project_dc_id + + # The project is public so user should see it + _, response = await sanic_client.get(f"/api/data/projects/{project_id}", headers=user_headers) + assert response.status_code == 200, response.text + # User is not part of the project and the data connector is private so they should not see any data connectors + _, response = await sanic_client.get("/api/data/data_connectors", headers=user_headers) + assert response.status_code == 200, response.text + assert len(response.json) == 0 + _, response = await sanic_client.get(f"/api/data/projects/{project_id}/data_connector_links", headers=user_headers) + assert len(response.json) == 0 + + # Anonymous users should see the project but not any of the DCs or the links + _, response = await sanic_client.get(f"/api/data/projects/{project_id}") + assert response.status_code == 200, response.text + _, response = await sanic_client.get("/api/data/data_connectors") + assert response.status_code == 200, response.text + assert len(response.json) == 0 + _, response = await sanic_client.get(f"/api/data/projects/{project_id}/data_connector_links") + assert len(response.json) == 0 + + # Add user to the project + payload = [ + { + "id": regular_user.id, + "role": "viewer", + } + ] + _, response = await sanic_client.patch( + f"/api/data/projects/{project_id}/members", headers=member_1_headers, json=payload + ) + assert response.status_code == 200, response.text + + # Now since the user is part of the project they should see only the project DC but not the private one from + # the group that user does not have access to + _, response = await sanic_client.get("/api/data/data_connectors", headers=user_headers) + assert response.status_code == 200, response.text + assert len(response.json) == 1 + assert response.json[0]["id"] == project_dc_id + _, response = await sanic_client.get(f"/api/data/projects/{project_id}/data_connector_links", headers=user_headers) + assert response.json[0]["data_connector_id"] == project_dc_id