diff --git a/CHANGELOG.md b/CHANGELOG.md index 49b831f..4044cd1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 For pre-1.0 releases, see [0.0.35 Changelog](https://github.com/noteable-io/origami/blob/0.0.35/CHANGELOG.md) ## [Unreleased] +### Added +- Programmatically share access to Spaces, Projects, and Notebooks/Files by email and access level. E.g. `await api_client.share_file(file_id, email, 'viewer')` + ### Changed - Removed `RuntimeError` in RTUClient catastrophic failure, top level applications (e.g. PA, Origamist) should define that behavior diff --git a/origami/clients/api.py b/origami/clients/api.py index a7b2e92..3fcf061 100644 --- a/origami/clients/api.py +++ b/origami/clients/api.py @@ -1,3 +1,4 @@ +import enum import logging import os import uuid @@ -18,6 +19,27 @@ logger = logging.getLogger(__name__) +class AccessLevel(enum.Enum): + owner = "role:owner" + contributor = "role:contributor" + commenter = "role:commenter" + viewer = "role:viewer" + executor = "role:executor" + + @classmethod + def from_str(cls, s: str): + for level in cls: + if level.name == s: + return level + raise ValueError(f"Invalid access level {s}") + + +class Resource(enum.Enum): + spaces = "spaces" + projects = "projects" + files = "files" + + class APIClient: def __init__( self, @@ -66,6 +88,46 @@ async def user_info(self) -> User: self.add_tags_and_contextvars(user_id=str(user.id)) return user + async def share_resource( + self, resource: Resource, resource_id: uuid.UUID, email: str, level: Union[str, AccessLevel] + ) -> int: + """ + Add another User as a collaborator to a Resource. + """ + user_lookup_endpoint = f"/{resource.value}/{resource_id}/shareable-users" + user_lookup_params = {"q": email} + user_lookup_resp = await self.client.get(user_lookup_endpoint, params=user_lookup_params) + user_lookup_resp.raise_for_status() + users = user_lookup_resp.json()["data"] + + if isinstance(level, str): + level = AccessLevel.from_str(level) + share_endpoint = f"/{resource.value}/{resource_id}/users" + for item in users: + user_id = item["id"] + share_body = {"access_level": level.value, "user_id": user_id} + share_resp = await self.client.put(share_endpoint, json=share_body) + share_resp.raise_for_status() + return len(users) + + async def unshare_resource(self, resource: Resource, resource_id: uuid.UUID, email: str) -> int: + """ + Remove access to a Resource for a User + """ + # Need to look this up still to go from email to user-id + user_lookup_endpoint = f"/{resource.value}/{resource_id}/shareable-users" + user_lookup_params = {"q": email} + user_lookup_resp = await self.client.get(user_lookup_endpoint, params=user_lookup_params) + user_lookup_resp.raise_for_status() + users = user_lookup_resp.json()["data"] + + for item in users: + user_id = item["id"] + unshare_endpoint = f"/{resource.value}/{resource_id}/users/{user_id}" + unshare_resp = await self.client.delete(unshare_endpoint) + unshare_resp.raise_for_status() + return len(users) + # Spaces are collections of Projects. Some "scoped" resources such as Secrets and Datasources # can also be attached to a Space and made available to all users of that Space. async def create_space(self, name: str, description: Optional[str] = None) -> Space: @@ -100,6 +162,20 @@ async def list_space_projects(self, space_id: uuid.UUID) -> List[Project]: projects = [Project.parse_obj(project) for project in resp.json()] return projects + async def share_space( + self, space_id: uuid.UUID, email: str, level: Union[str, AccessLevel] + ) -> int: + """ + Add another user as a collaborator to a Space. + """ + return await self.share_resource(Resource.spaces, space_id, email, level) + + async def unshare_space(self, space_id: uuid.UUID, email: str) -> int: + """ + Remove access to a Space for a User + """ + return await self.unshare_resource(Resource.spaces, space_id, email) + # Projects are collections of Files, including Notebooks. When a Kernel is launched for a # Notebook, all Files in the Project are volume mounted into the Kernel container at startup. async def create_project( @@ -138,6 +214,20 @@ async def delete_project(self, project_id: uuid.UUID) -> Project: project = Project.parse_obj(resp.json()) return project + async def share_project( + self, project_id: uuid.UUID, email: str, level: Union[str, AccessLevel] + ) -> int: + """ + Add another User as a collaborator to a Project. + """ + return await self.share_resource(Resource.projects, project_id, email, level) + + async def unshare_project(self, project_id: uuid.UUID, email: str) -> int: + """ + Remove access to a Project for a User + """ + return await self.unshare_resource(Resource.projects, project_id, email) + async def list_project_files(self, project_id: uuid.UUID) -> List[File]: """List all Files in a Project. Files do not have presigned download urls included here.""" self.add_tags_and_contextvars(project_id=str(project_id)) @@ -265,6 +355,20 @@ async def delete_file(self, file_id: uuid.UUID) -> File: file = File.parse_obj(resp.json()) return file + async def share_file( + self, file_id: uuid.UUID, email: str, level: Union[str, AccessLevel] + ) -> int: + """ + Add another User as a collaborator to a Notebook or File. + """ + return await self.share_resource(Resource.files, file_id, email, level) + + async def unshare_file(self, file_id: uuid.UUID, email: str) -> int: + """ + Remove access to a Notebook or File for a User + """ + return await self.unshare_resource(Resource.files, file_id, email) + async def get_datasources_for_notebook(self, file_id: uuid.UUID) -> List[DataSource]: """Return a list of Datasources that can be used in SQL cells within a Notebook""" self.add_tags_and_contextvars(file_id=str(file_id))