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))