Repository: gtalarico/pyairtable
Pull request #337 (2024-02-10 01:32:11)

Support for managing permissions and shares
This is the last checkbox in the "Modify collaborations/sharing" section of the roadmap in #249. It supports the following operations:
  - [x] [Update collaborator base permission](https://airtable.com/developers/web/api/update-collaborator-base-permission)
  - [x] [Update interface collaborator](https://airtable.com/developers/web/api/update-interface-collaborator)
  - [x] [Update workspace collaborator](https://airtable.com/developers/web/api/update-workspace-collaborator)
  - [x] [Delete base collaborator](https://airtable.com/developers/web/api/delete-base-collaborator)
  - [x] [Delete interface collaborator](https://airtable.com/developers/web/api/delete-interface-collaborator)
  - [x] [Delete workspace collaborator](https://airtable.com/developers/web/api/delete-workspace-collaborator)
  - [x] [Add base collaborator](https://airtable.com/developers/web/api/add-base-collaborator)
  - [x] [Add interface collaborator](https://airtable.com/developers/web/api/add-interface-collaborator)
  - [x] [Add workspace collaborator](https://airtable.com/developers/web/api/add-workspace-collaborator)
  - [x] [Delete base invite](https://airtable.com/developers/web/api/delete-base-invite)
  - [x] [Delete interface invite](https://airtable.com/developers/web/api/delete-interface-invite)
  - [x] [Delete workspace invite](https://airtable.com/developers/web/api/delete-workspace-invite)
  - [x] [Delete share](https://airtable.com/developers/web/api/delete-share)
  - [x] [Manage share](https://airtable.com/developers/web/api/manage-share)
  - [x] [Update workspace restrictions](https://airtable.com/developers/web/api/update-workspace-restrictions)

See changes to `enterprise.rst` for examples of how the API works.


Patch:
diff --git a/docs/source/_substitutions.rst b/docs/source/_substitutions.rst
index d0fd0b8..c95a562 100644
--- a/docs/source/_substitutions.rst
+++ b/docs/source/_substitutions.rst
@@ -70,6 +70,9 @@
     If ``True``, will fetch information from the metadata API and validate the ID/name exists,
     raising ``KeyError`` if it does not.
 
+.. |kwarg_permission_level| replace::
+    See `application permission levels <https://airtable.com/developers/web/api/model/application-permission-levels>`__.
+
 .. |warn| unicode:: U+26A0 .. WARNING SIGN
 
 .. |enterprise_only| replace:: |warn| This feature is only available on Enterprise billing plans.
diff --git a/docs/source/changelog.rst b/docs/source/changelog.rst
index 0ed91d5..bea3bfd 100644
--- a/docs/source/changelog.rst
+++ b/docs/source/changelog.rst
@@ -5,6 +5,9 @@ Changelog
 2.3.0 (TBD)
 ------------------------
 
+* Added support for :ref:`managing permissions and shares`
+  and :ref:`managing users`.
+  - `PR #337 <https://github.com/gtalarico/pyairtable/pull/337>`_.
 * Added :meth:`Enterprise.audit_log <pyairtable.Enterprise.audit_log>`
   to iterate page-by-page through `audit log events <https://airtable.com/developers/web/api/audit-logs-overview>`__.
   - `PR #330 <https://github.com/gtalarico/pyairtable/pull/330>`_.
diff --git a/docs/source/enterprise.rst b/docs/source/enterprise.rst
index 871c231..a1d5c43 100644
--- a/docs/source/enterprise.rst
+++ b/docs/source/enterprise.rst
@@ -1,10 +1,14 @@
 .. include:: _substitutions.rst
 .. include:: _warn_latest.rst
 
+
 Enterprise Features
 ==============================
 
 
+Retrieving information
+----------------------
+
 pyAirtable exposes a number of classes and methods for interacting with enterprise organizations.
 The following methods are only available on an `Enterprise plan <https://airtable.com/pricing>`__.
 If you call one of them against a base that is not part of an enterprise workspace, Airtable will
@@ -25,5 +29,132 @@ return a 404 error, and pyAirtable will add a reminder to the exception to check
 .. automethod:: pyairtable.Enterprise.info
     :noindex:
 
+
+Retrieving audit logs
+---------------------
+
 .. automethod:: pyairtable.Enterprise.audit_log
     :noindex:
+
+
+Managing permissions and shares
+-------------------------------
+
+You can use pyAirtable to change permissions on a base or workspace
+via the following methods exposed on schema objects.
+
+If for some reason you need to call these API endpoints without first retrieving
+schema information, you might consider calling :meth:`~pyairtable.Api.request` directly.
+
+`Add base collaborator <https://airtable.com/developers/web/api/add-base-collaborator>`__
+
+    >>> base.collaborators().add_user("usrUserId", "read")
+    >>> base.collaborators().add_group("ugpGroupId", "edit")
+    >>> base.collaborators().add("user", "usrUserId", "comment")
+
+`Add interface collaborator <https://airtable.com/developers/web/api/add-interface-collaborator>`__
+
+    >>> base.collaborators().interfaces[pbd].add_user("usrUserId", "read")
+    >>> base.collaborators().interfaces[pbd].add_group("ugpGroupId", "read")
+    >>> base.collaborators().interfaces[pbd].add("user", "usrUserId", "read")
+
+`Add workspace collaborator <https://airtable.com/developers/web/api/add-workspace-collaborator>`__
+
+    >>> workspace.collaborators().add_user("usrUserId", "read")
+    >>> workspace.collaborators().add_group("ugpGroupId", "edit")
+    >>> workspace.collaborators().add("user", "usrUserId", "comment")
+
+`Update collaborator base permission <https://airtable.com/developers/web/api/update-collaborator-base-permission>`__
+
+    >>> base.collaborators().update("usrUserId", "edit")
+    >>> base.collaborators().update("ugpGroupId", "edit")
+
+`Update interface collaborator <https://airtable.com/developers/web/api/update-interface-collaborator>`__
+
+    >>> base.collaborators().interfaces[pbd].update("usrUserId", "edit")
+    >>> base.collaborators().interfaces[pbd].update("ugpGroupId", "edit")
+
+`Update workspace collaborator <https://airtable.com/developers/web/api/update-workspace-collaborator>`__
+
+    >>> workspace.collaborators().update("usrUserId", "edit")
+    >>> workspace.collaborators().update("ugpGroupId", "edit")
+
+`Delete base collaborator <https://airtable.com/developers/web/api/delete-base-collaborator>`__
+
+    >>> base.collaborators().remove("usrUserId")
+    >>> base.collaborators().remove("ugpGroupId")
+
+`Delete interface collaborator <https://airtable.com/developers/web/api/delete-interface-collaborator>`__
+
+    >>> base.collaborators().interfaces[pbd].remove("usrUserId")
+    >>> base.collaborators().interfaces[pbd].remove("ugpGroupId")
+
+`Delete workspace collaborator <https://airtable.com/developers/web/api/delete-workspace-collaborator>`__
+
+    >>> workspace.collaborators().remove("usrUserId")
+    >>> workspace.collaborators().remove("ugpGroupId")
+
+`Delete base invite <https://airtable.com/developers/web/api/delete-base-invite>`__
+
+    >>> base.collaborators().invite_links.via_base[0].delete()
+    >>> workspace.collaborators().invite_links.via_base[0].delete()
+
+`Delete interface invite <https://airtable.com/developers/web/api/delete-interface-invite>`__
+
+    >>> base.collaborators().interfaces[pbd].invite_links[0].delete()
+
+`Delete workspace invite <https://airtable.com/developers/web/api/delete-workspace-invite>`__
+
+    >>> base.collaborators().invite_links.via_workspace[0].delete()
+    >>> workspace.collaborators().invite_links.via_workspace[0].delete()
+
+`Manage share <https://airtable.com/developers/web/api/manage-share>`__
+
+    >>> share = base.shares()[0]
+    >>> share.disable()
+    >>> share.enable()
+
+`Delete share <https://airtable.com/developers/web/api/delete-share>`__
+
+    >>> share.delete()
+
+`Update workspace restrictions <https://airtable.com/developers/web/api/update-workspace-restrictions>`__
+
+    >>> r = workspace.collaborators().restrictions
+    >>> r.invite_creation = "unrestricted"
+    >>> r.share_creation = "onlyOwners"
+    >>> r.save()
+
+
+Managing users
+-------------------
+
+You can use pyAirtable to manage an enterprise's users
+via the following methods.
+
+`Manage user <https://airtable.com/developers/web/api/manage-user>`__
+
+    >>> user = enterprise.user("usrUserId")
+    >>> user.state = "deactivated"
+    >>> user.email = user.email.replace("@", "+deactivated@")
+    >>> user.save()
+
+`Logout user <https://airtable.com/developers/web/api/logout-user>`__
+
+    >>> user.logout()
+
+`Delete user by id <https://airtable.com/developers/web/api/delete-user-by-id>`__
+
+    >>> user.delete()
+
+`Remove user from enterprise <https://airtable.com/developers/web/api/remove-user-from-enterprise>`__
+
+    >>> enterprise.remove_user("usrUserId", replacement="usrOtherUserId")
+
+`Manage user membership <https://airtable.com/developers/web/api/manage-user-membership>`__
+
+    >>> enterprise.claim_users({"userId": "managed"})
+
+`Delete users by email <https://airtable.com/developers/web/api/delete-users-by-email>`__
+
+    >>> enterprise.delete_users(["foo@example.com", "bar@example.com"])
diff --git a/pyairtable/api/api.py b/pyairtable/api/api.py
index a2708af..3f55281 100644
--- a/pyairtable/api/api.py
+++ b/pyairtable/api/api.py
@@ -1,4 +1,5 @@
 import posixpath
+from functools import partialmethod
 from typing import Any, Dict, Iterator, List, Optional, Sequence, Tuple, TypeVar, Union
 
 import requests
@@ -129,15 +130,14 @@ class Api:
         Return a schema object that represents all bases available via the API.
         """
         url = self.build_url("meta/bases")
-        return Bases.parse_obj(
-            {
-                "bases": [
-                    base_info
-                    for page in self.iterate_requests("GET", url)
-                    for base_info in page["bases"]
-                ]
-            }
-        )
+        data = {
+            "bases": [
+                base_info
+                for page in self.iterate_requests("GET", url)
+                for base_info in page["bases"]
+            ]
+        }
+        return Bases.from_api(data, self)
 
     def _base_from_info(self, base_info: Bases.Info) -> "pyairtable.api.base.Base":
         return pyairtable.api.base.Base(
@@ -268,6 +268,11 @@ class Api:
         response = self.session.send(prepared, timeout=self.timeout)
         return self._process_response(response)
 
+    get = partialmethod(request, "GET")
+    post = partialmethod(request, "POST")
+    patch = partialmethod(request, "PATCH")
+    delete = partialmethod(request, "DELETE")
+
     def _process_response(self, response: requests.Response) -> Any:
         try:
             response.raise_for_status()
diff --git a/pyairtable/api/base.py b/pyairtable/api/base.py
index 75bf47a..f11ab29 100644
--- a/pyairtable/api/base.py
+++ b/pyairtable/api/base.py
@@ -159,7 +159,7 @@ class Base:
         payload = {"name": name, "fields": fields}
         if description:
             payload["description"] = description
-        response = self.api.request("POST", url, json=payload)
+        response = self.api.post(url, json=payload)
         return self.table(response["id"], validate=True)
 
     @property
@@ -187,7 +187,7 @@ class Base:
         """
         url = self.meta_url("tables")
         params = {"include": ["visibleFieldIds"]}
-        data = self.api.request("GET", url, params=params)
+        data = self.api.get(url, params=params)
         return BaseSchema.from_api(data, self.api, context=self)
 
     @property
@@ -215,7 +215,7 @@ class Base:
                 )
             ]
         """
-        response = self.api.request("GET", self.webhooks_url)
+        response = self.api.get(self.webhooks_url)
         return [
             Webhook.from_api(data, self.api, context=self)
             for data in response["webhooks"]
@@ -277,12 +277,12 @@ class Base:
                 can also provide :class:`~pyairtable.models.webhook.WebhookSpecification`.
         """
         if isinstance(spec, dict):
-            spec = WebhookSpecification.parse_obj(spec)
+            spec = WebhookSpecification.from_api(spec, self.api)
 
         create = CreateWebhook(notification_url=notify_url, specification=spec)
         request = create.dict(by_alias=True, exclude_unset=True)
-        response = self.api.request("POST", self.webhooks_url, json=request)
-        return CreateWebhookResponse.parse_obj(response)
+        response = self.api.post(self.webhooks_url, json=request)
+        return CreateWebhookResponse.from_api(response, self.api)
 
     @enterprise_only
     @cache_unless_forced
@@ -291,8 +291,8 @@ class Base:
         Retrieve `base collaborators <https://airtable.com/developers/web/api/get-base-collaborators>`__.
         """
         params = {"include": ["collaborators", "inviteLinks", "interfaces"]}
-        data = self.api.request("GET", self.meta_url(), params=params)
-        return BaseCollaborators.parse_obj(data)
+        data = self.api.get(self.meta_url(), params=params)
+        return BaseCollaborators.from_api(data, self.api, context=self)
 
     @enterprise_only
     @cache_unless_forced
@@ -300,8 +300,9 @@ class Base:
         """
         Retrieve `base shares <https://airtable.com/developers/web/api/list-shares>`__.
         """
-        data = self.api.request("GET", self.meta_url("shares"))
-        return BaseShares.parse_obj(data).shares
+        data = self.api.get(self.meta_url("shares"))
+        shares_obj = BaseShares.from_api(data, self.api, context=self)
+        return shares_obj.shares
 
     @enterprise_only
     def delete(self) -> None:
@@ -312,4 +313,4 @@ class Base:
             >>> base = api.base("appMxESAta6clCCwF")
             >>> base.delete()
         """
-        self.api.request("DELETE", self.meta_url())
+        self.api.delete(self.meta_url())
diff --git a/pyairtable/api/enterprise.py b/pyairtable/api/enterprise.py
index 10a1c24..5eaa711 100644
--- a/pyairtable/api/enterprise.py
+++ b/pyairtable/api/enterprise.py
@@ -1,6 +1,7 @@
 import datetime
-from typing import Iterable, Iterator, List, Optional, Union
+from typing import Any, Dict, Iterable, Iterator, List, Literal, Optional, Union
 
+from pyairtable.models._base import AirtableModel, update_forward_refs
 from pyairtable.models.audit import AuditLogResponse
 from pyairtable.models.schema import EnterpriseInfo, UserGroup, UserInfo
 from pyairtable.utils import (
@@ -8,7 +9,6 @@ from pyairtable.utils import (
     coerce_iso_str,
     coerce_list_str,
     enterprise_only,
-    is_user_id,
 )
 
 
@@ -37,8 +37,8 @@ class Enterprise:
         Retrieve basic information about the enterprise, caching the result.
         """
         params = {"include": ["collaborators", "inviteLinks"]}
-        payload = self.api.request("GET", self.url, params=params)
-        return EnterpriseInfo.parse_obj(payload)
+        response = self.api.get(self.url, params=params)
+        return EnterpriseInfo.from_api(response, self.api)
 
     def group(self, group_id: str, collaborations: bool = True) -> UserGroup:
         """
@@ -51,7 +51,7 @@ class Enterprise:
         """
         params = {"include": ["collaborations"] if collaborations else []}
         url = self.api.build_url(f"meta/groups/{group_id}")
-        payload = self.api.request("GET", url, params=params)
+        payload = self.api.get(url, params=params)
         return UserGroup.parse_obj(payload)
 
     def user(self, id_or_email: str, collaborations: bool = True) -> UserInfo:
@@ -86,8 +86,7 @@ class Enterprise:
         for value in ids_or_emails:
             (emails if "@" in value else user_ids).append(value)
 
-        response = self.api.request(
-            method="GET",
+        response = self.api.get(
             url=f"{self.url}/users",
             params={
                 "id": user_ids,
@@ -226,6 +225,150 @@ class Enterprise:
             if page_limit is not None and count >= page_limit:
                 return
 
+    def remove_user(
+        self,
+        user_id: str,
+        replacement: Optional[str] = None,
+    ) -> "UserRemoved":
+        """
+        Unshare a user from all enterprise workspaces, bases, and interfaces.
+        If applicable, the user will also be removed from as an enterprise admin.
+
+        See `Remove user from enterprise <https://airtable.com/developers/web/api/remove-user-from-enterprise>`__
+        for more information.
+
+        Args:
+            user_id: The user ID.
+            replacement: If the user is the sole owner of any workspaces, you must
+                specify a replacement user ID to be added as the new owner of such
+                workspaces. If the user is not the sole owner of any workspaces,
+                this is optional and will be ignored if provided.
+        """
+        url = f"{self.url}/users/{user_id}/remove"
+        payload: Dict[str, Any] = {"isDryRun": False}
+        if replacement:
+            payload["replacementOwnerId"] = replacement
+        response = self.api.post(url, json=payload)
+        return UserRemoved.from_api(response, self.api, context=self)
+
+    def claim_users(
+        self, users: Dict[str, Literal["managed", "unmanaged"]]
+    ) -> "ClaimUsersResponse":
+        """
+        Batch manage organizations enterprise account users. This endpoint allows you
+        to change a user's membership status from being unmanaged to being an
+        organization member, and vice versa.
+
+        See `Manage user membership <https://airtable.com/developers/web/api/manage-user-membership>`__
+        for more information.
+
+        Args:
+            users: A ``dict`` mapping user IDs or emails to the desired state,
+                either ``"managed"`` or ``"unmanaged"``.
+        """
+        payload = {
+            "users": [
+                {
+                    ("email" if "@" in key else "id"): key,
+                    "state": value,
+                }
+                for (key, value) in users.items()
+            ]
+        }
+        response = self.api.post(f"{self.url}/users/claim", json=payload)
+        return ClaimUsersResponse.from_api(response, self.api, context=self)
+
+    def delete_users(self, emails: Iterable[str]) -> "DeleteUsersResponse":
+        """
+        Delete multiple users by email.
+
+        Args:
+            emails: A list or other iterable of email addresses.
+        """
+        response = self.api.delete(f"{self.url}/users", params={"email": list(emails)})
+        return DeleteUsersResponse.from_api(response, self.api, context=self)
+
+
+class UserRemoved(AirtableModel):
+    """
+    Returned from the `Remove user from enterprise <https://airtable.com/developers/web/api/remove-user-from-enterprise>`__
+    endpoint.
+    """
+
+    was_user_removed_as_admin: bool
+    shared: "UserRemoved.Shared"
+    unshared: "UserRemoved.Unshared"
+
+    class Shared(AirtableModel):
+        workspaces: List["UserRemoved.Shared.Workspace"]
+
+        class Workspace(AirtableModel):
+            permission_level: str
+            workspace_id: str
+            workspace_name: str
+            user_id: str = ""
+
+    class Unshared(AirtableModel):
+        bases: List["UserRemoved.Unshared.Base"]
+        interfaces: List["UserRemoved.Unshared.Interface"]
+        workspaces: List["UserRemoved.Unshared.Workspace"]
+
+        class Base(AirtableModel):
+            user_id: str
+            base_id: str
+            base_name: str
+            former_permission_level: str
+
+        class Interface(AirtableModel):
+            user_id: str
+            base_id: str
+            interface_id: str
+            interface_name: str
+            former_permission_level: str
+
+        class Workspace(AirtableModel):
+            user_id: str
+            former_permission_level: str
+            workspace_id: str
+            workspace_name: str
+
+
+class DeleteUsersResponse(AirtableModel):
+    """
+    Returned from the `Delete users by email <https://airtable.com/developers/web/api/delete-users-by-email>`__
+    endpoint.
+    """
+
+    deleted_users: List["DeleteUsersResponse.UserInfo"]
+    errors: List["DeleteUsersResponse.Error"]
+
+    class UserInfo(AirtableModel):
+        id: str
+        email: str
+
+    class Error(AirtableModel):
+        type: str
+        email: str
+        message: Optional[str] = None
+
+
+class ClaimUsersResponse(AirtableModel):
+    """
+    Returned from the `Manage user membership <https://airtable.com/developers/web/api/manage-user-membership>`__
+    endpoint.
+    """
+
+    errors: List["ClaimUsersResponse.Error"]
+
+    class Error(AirtableModel):
+        id: Optional[str] = None
+        email: Optional[str] = None
+        type: str
+        message: str
+
+
+update_forward_refs(vars())
+
 
 # These are at the bottom of the module to avoid circular imports
 import pyairtable.api.api  # noqa
diff --git a/pyairtable/api/table.py b/pyairtable/api/table.py
index eff8534..0145ce5 100644
--- a/pyairtable/api/table.py
+++ b/pyairtable/api/table.py
@@ -199,7 +199,7 @@ class Table:
             user_locale: |kwarg_user_locale|
             return_fields_by_field_id: |kwarg_return_fields_by_field_id|
         """
-        record = self.api.request("get", self.record_url(record_id), options=options)
+        record = self.api.get(self.record_url(record_id), options=options)
         return assert_typed_dict(RecordDict, record)
 
     def iterate(self, **options: Any) -> Iterator[List[RecordDict]]:
@@ -304,8 +304,7 @@ class Table:
             typecast: |kwarg_typecast|
             return_fields_by_field_id: |kwarg_return_fields_by_field_id|
         """
-        created = self.api.request(
-            method="post",
+        created = self.api.post(
             url=self.url,
             json={
                 "fields": fields,
@@ -350,8 +349,7 @@ class Table:
 
         for chunk in self.api.chunked(records):
             new_records = [{"fields": fields} for fields in chunk]
-            response = self.api.request(
-                method="post",
+            response = self.api.post(
                 url=self.url,
                 json={
                     "records": new_records,
@@ -523,7 +521,7 @@ class Table:
         """
         return assert_typed_dict(
             RecordDeletedDict,
-            self.api.request("delete", self.record_url(record_id)),
+            self.api.delete(self.record_url(record_id)),
         )
 
     def batch_delete(self, record_ids: Iterable[RecordId]) -> List[RecordDeletedDict]:
@@ -548,7 +546,7 @@ class Table:
         record_ids = list(record_ids)
 
         for chunk in self.api.chunked(record_ids):
-            result = self.api.request("delete", self.url, params={"records[]": chunk})
+            result = self.api.delete(self.url, params={"records[]": chunk})
             deleted_records += assert_typed_dicts(RecordDeletedDict, result["records"])
 
         return deleted_records
@@ -615,7 +613,7 @@ class Table:
             text: The text of the comment. Use ``@[usrIdentifier]`` to mention users.
         """
         url = self.record_url(record_id, "comments")
-        response = self.api.request("POST", url, json={"text": text})
+        response = self.api.post(url, json={"text": text})
         return pyairtable.models.Comment.from_api(
             response, self.api, context={"record_url": self.record_url(record_id)}
         )
@@ -663,11 +661,11 @@ class Table:
             request["description"] = description
         if options:
             request["options"] = options
-        response = self.api.request("POST", self.meta_url("fields"), json=request)
+        response = self.api.post(self.meta_url("fields"), json=request)
         # This hopscotch ensures that the FieldSchema object we return has an API and a URL,
         # and that developers don't need to reload our schema to be able to access it.
         field_schema = parse_field_schema(response)
-        field_schema.set_api(
+        field_schema._set_api(
             self.api,
             context={
                 "base": self.base,
diff --git a/pyairtable/api/types.py b/pyairtable/api/types.py
index dc72cca..5ab90ca 100644
--- a/pyairtable/api/types.py
+++ b/pyairtable/api/types.py
@@ -2,6 +2,7 @@
 pyAirtable provides a number of type aliases and TypedDicts which are used as inputs
 and return values to various pyAirtable methods.
 """
+
 from functools import lru_cache
 from typing import Any, Dict, List, Optional, Type, TypeVar, Union, cast
 
@@ -25,6 +26,10 @@ Timestamp: TypeAlias = str
 FieldName: TypeAlias = str
 
 
+class NestedIdDict(TypedDict):
+    id: str
+
+
 class AITextDict(TypedDict, total=False):
     """
     A ``dict`` representing text generated by AI.
@@ -179,6 +184,29 @@ class CollaboratorEmailDict(TypedDict):
     email: str
 
 
+class AddUserCollaboratorDict(TypedDict):
+    """
+    Used to add a user as a collaborator to a base, workspace, or interface.
+    """
+
+    user: NestedIdDict
+    permissionLevel: str
+
+
+class AddGroupCollaboratorDict(TypedDict):
+    """
+    Used to add a group as a collaborator to a base, workspace, or interface.
+    """
+
+    group: NestedIdDict
+    permissionLevel: str
+
+
+AddCollaboratorDict: TypeAlias = Union[
+    AddUserCollaboratorDict, AddGroupCollaboratorDict
+]
+
+
 #: Represents the types of values that we might receive from the API.
 #: At present, is an alias for ``Any`` because we don't want to lose
 #: forward compatibility with any changes Airtable makes in the future.
diff --git a/pyairtable/api/workspace.py b/pyairtable/api/workspace.py
index fbecf48..3da75fd 100644
--- a/pyairtable/api/workspace.py
+++ b/pyairtable/api/workspace.py
@@ -45,7 +45,7 @@ class Workspace:
         """
         url = self.api.build_url("meta/bases")
         payload = {"name": name, "workspaceId": self.id, "tables": list(tables)}
-        response = self.api.request("POST", url, json=payload)
+        response = self.api.post(url, json=payload)
         return self.api.base(response["id"], validate=True, force=True)
 
     # Everything below here requires .info() and is therefore Enterprise-only
@@ -60,8 +60,8 @@ class Workspace:
         See https://airtable.com/developers/web/api/get-workspace-collaborators
         """
         params = {"include": ["collaborators", "inviteLinks"]}
-        payload = self.api.request("GET", self.url, params=params)
-        return WorkspaceCollaborators.parse_obj(payload)
+        payload = self.api.get(self.url, params=params)
+        return WorkspaceCollaborators.from_api(payload, self.api, context=self)
 
     @enterprise_only
     def bases(self) -> List["pyairtable.api.base.Base"]:
@@ -89,7 +89,7 @@ class Workspace:
             >>> ws = api.workspace("wspmhESAta6clCCwF")
             >>> ws.delete()
         """
-        self.api.request("DELETE", self.url)
+        self.api.delete(self.url)
 
     @enterprise_only
     def move_base(
@@ -114,7 +114,7 @@ class Workspace:
         if index is not None:
             payload["targetIndex"] = index
         url = self.url + "/moveBase"
-        self.api.request("POST", url, json=payload)
+        self.api.post(url, json=payload)
 
 
 # These are at the bottom of the module to avoid circular imports
diff --git a/pyairtable/models/_base.py b/pyairtable/models/_base.py
index 5703f68..479d201 100644
--- a/pyairtable/models/_base.py
+++ b/pyairtable/models/_base.py
@@ -29,16 +29,14 @@ class AirtableModel(pydantic.BaseModel):
 
     _raw: Any = pydantic.PrivateAttr()
 
-    @classmethod
-    def parse_obj(cls, obj: Any) -> SelfType:
-        instance = super().parse_obj(obj)
-        instance._raw = obj
-        return instance
+    def __init__(self, **data: Any) -> None:
+        super().__init__(**data)
+        self._raw = data
 
     @classmethod
     def from_api(
         cls,
-        obj: Any,
+        obj: Dict[str, Any],
         api: "pyairtable.api.api.Api",
         *,
         context: Optional[Any] = None,
@@ -55,7 +53,7 @@ class AirtableModel(pydantic.BaseModel):
                 which will be used as arguments to ``str.format()`` when constructing
                 the URL for a :class:`~pyairtable.models._base.RestfulModel`.
         """
-        instance = cls.parse_obj(obj)
+        instance = cls(**obj)
         cascade_api(instance, api, context=context)
         return instance
 
@@ -95,8 +93,8 @@ def cascade_api(
         for value in obj:
             cascade_api(value, api, context=context)
     if isinstance(obj, dict):
-        for value in obj.values():
-            cascade_api(value, api, context=context)
+        for key, value in obj.items():
+            cascade_api(value, api, context={**context, "key": key})
     if not isinstance(obj, AirtableModel):
         return
 
@@ -104,9 +102,9 @@ def cascade_api(
     # If it's a ModelNamedThis, the key will be model_named_this.
     context = {**context, _context_name(obj): obj}
 
-    # This is what we came here for
     if isinstance(obj, RestfulModel):
-        obj.set_api(api, context=context)
+        # This is what we came here for; set the API and URL on the RESTful model.
+        obj._set_api(api, context=context)
 
     # Find and apply API/context to nested models in every Pydantic field.
     for field_name in type(obj).__fields__:
@@ -127,22 +125,40 @@ class RestfulModel(AirtableModel):
 
     _api: "pyairtable.api.api.Api" = pydantic.PrivateAttr()
     _url: str = pydantic.PrivateAttr(default="")
+    _url_context: Any = None
 
     def __init_subclass__(cls, **kwargs: Any) -> None:
         cls.__url_pattern = kwargs.pop("url", cls.__url_pattern)
         super().__init_subclass__()
 
-    def set_api(self, api: "pyairtable.api.api.Api", context: Dict[str, Any]) -> None:
+    def _set_api(self, api: "pyairtable.api.api.Api", context: Dict[str, Any]) -> None:
         """
-        Set a link to the API and builds the REST URL used for this resource.
-
-        :meta private:
+        Set a link to the API and build the REST URL used for this resource.
         """
         self._api = api
-        self._url = self.__url_pattern.format(**context, self=self)
+        self._url_context = context
+        try:
+            self._url = self.__url_pattern.format(**context, self=self)
+        except (KeyError, AttributeError) as exc:
+            exc.args = (
+                *exc.args,
+                {k: v for (k, v) in context.items() if k != "__visited__"},
+            )
+            raise
         if self._url and not self._url.startswith("http"):
             self._url = api.build_url(self._url)
 
+    def _reload(self, obj: Optional[Dict[str, Any]] = None) -> None:
+        """
+        Reload the model's contents from the given object, or by making a GET request to the API.
+        """
+        if obj is None:
+            obj = self._api.get(self._url)
+        copyable = type(self).from_api(obj, self._api, context=self._url_context)
+        self.__dict__.update(
+            {key: copyable.__dict__.get(key) for key in type(self).__fields__}
+        )
+
 
 class CanDeleteModel(RestfulModel):
     """
@@ -182,6 +198,8 @@ class CanUpdateModel(RestfulModel):
     __writable: ClassVar[Optional[Iterable[str]]] = None
     __readonly: ClassVar[Optional[Iterable[str]]] = None
     __save_none: ClassVar[bool] = True
+    __save_http_method: ClassVar[str] = "PATCH"
+    __reload_after_save: ClassVar[bool] = True
 
     def __init_subclass__(cls, **kwargs: Any) -> None:
         if "writable" in kwargs and "readonly" in kwargs:
@@ -189,6 +207,10 @@ class CanUpdateModel(RestfulModel):
         cls.__writable = kwargs.pop("writable", cls.__writable)
         cls.__readonly = kwargs.pop("readonly", cls.__readonly)
         cls.__save_none = bool(kwargs.pop("save_null_values", cls.__save_none))
+        cls.__save_http_method = kwargs.pop("save_method", cls.__save_http_method)
+        cls.__reload_after_save = bool(
+            kwargs.pop("reload_after_save", cls.__reload_after_save)
+        )
         if cls.__writable:
             _append_docstring_text(
                 cls,
@@ -222,15 +244,9 @@ class CanUpdateModel(RestfulModel):
             exclude=exclude,
             exclude_none=(not self.__save_none),
         )
-        response = self._api.request("PATCH", self._url, json=data)
-        copyable = type(self).parse_obj(response)
-        self.__dict__.update(
-            {
-                key: value
-                for (key, value) in copyable.__dict__.items()
-                if key in type(self).__fields__
-            }
-        )
+        response = self._api.request(self.__save_http_method, self._url, json=data)
+        if self.__reload_after_save:
+            self._reload(response)
 
     def __setattr__(self, name: str, value: Any) -> None:
         # Prevents implementers from changing values on readonly or non-writable fields.
diff --git a/pyairtable/models/schema.py b/pyairtable/models/schema.py
index 37f0591..0de7056 100644
--- a/pyairtable/models/schema.py
+++ b/pyairtable/models/schema.py
@@ -1,17 +1,37 @@
+import importlib
 from functools import partial
-from typing import Any, Dict, List, Literal, Optional, TypeVar, Union
+from typing import Any, Dict, Iterable, List, Literal, Optional, TypeVar, Union, cast
 
 from typing_extensions import TypeAlias
 
 from pyairtable._compat import pydantic
+from pyairtable.api.types import AddCollaboratorDict
 
-from ._base import AirtableModel, CanDeleteModel, CanUpdateModel, update_forward_refs
+from ._base import (
+    AirtableModel,
+    CanDeleteModel,
+    CanUpdateModel,
+    RestfulModel,
+    update_forward_refs,
+)
 
 _T = TypeVar("_T", bound=Any)
 _FL = partial(pydantic.Field, default_factory=list)
 _FD = partial(pydantic.Field, default_factory=dict)
 
 
+def _F(classname: str, **kwargs: Any) -> Any:
+    def _create_default_from_classname() -> Any:
+        this_module = importlib.import_module(__name__)
+        obj = this_module
+        for segment in classname.split("."):
+            obj = getattr(obj, segment)
+        return obj
+
+    kwargs["default_factory"] = _create_default_from_classname
+    return pydantic.Field(**kwargs)
+
+
 def _find(collection: List[_T], id_or_name: str) -> _T:
     """
     For use on a collection model to find objects by either id or name.
@@ -28,6 +48,95 @@ def _find(collection: List[_T], id_or_name: str) -> _T:
     return items_by_name[id_or_name]
 
 
+class _Collaborators(RestfulModel):
+    """
+    Mixin for use with RestfulModel subclasses that have a /collaborators endpoint.
+    """
+
+    def add_user(self, user_id: str, permission_level: str) -> None:
+        """
+        Add a user as a collaborator to this Airtable object.
+
+        Args:
+            user_id: The user ID.
+            permission_level: |kwarg_permission_level|
+        """
+        self.add("user", user_id, permission_level)
+
+    def add_group(self, group_id: str, permission_level: str) -> None:
+        """
+        Add a group as a collaborator to this Airtable object.
+
+        Args:
+            group_id: The group ID.
+            permission_level: |kwarg_permission_level|
+        """
+        self.add("group", group_id, permission_level)
+
+    def add(
+        self,
+        collaborator_type: str,
+        collaborator_id: str,
+        permission_level: str,
+    ) -> None:
+        """
+        Add a user or group as a collaborator to this Airtable object.
+
+        Args:
+            collaborator_type: Either ``'user'`` or ``'group'``.
+            collaborator_id: The user or group ID.
+            permission_level: |kwarg_permission_level|
+        """
+        if collaborator_type not in ("user", "group"):
+            raise ValueError("collaborator_type must be 'user' or 'group'")
+        self.add_collaborators(
+            [
+                cast(
+                    AddCollaboratorDict,
+                    {
+                        collaborator_type: {"id": collaborator_id},
+                        "permissionLevel": permission_level,
+                    },
+                )
+            ]
+        )
+
+    def add_collaborators(self, collaborators: Iterable[AddCollaboratorDict]) -> None:
+        """
+        Add multiple collaborators to this Airtable object.
+
+        Args:
+            collaborators: A list of ``dict`` that conform to the specification
+                laid out in the `Add base collaborator <https://airtable.com/developers/web/api/add-base-collaborator#request-collaborators>`__
+                API documentation.
+        """
+        payload = {"collaborators": list(collaborators)}
+        self._api.post(f"{self._url}/collaborators", json=payload)
+        self._reload()
+
+    def update(self, collaborator_id: str, permission_level: str) -> None:
+        """
+        Change the permission level granted to a user or group.
+
+        Args:
+            collaborator_id: The user or group ID.
+            permission_level: |kwarg_permission_level|
+        """
+        self._api.patch(
+            f"{self._url}/collaborators/{collaborator_id}",
+            json={"permissionLevel": permission_level},
+        )
+
+    def remove(self, collaborator_id: str) -> None:
+        """
+        Remove a user or group as a collaborator.
+
+        Args:
+            collaborator_id: The user or group ID.
+        """
+        self._api.delete(f"{self._url}/collaborators/{collaborator_id}")
+
+
 class Bases(AirtableModel):
     """
     The list of bases visible to the API token.
@@ -49,7 +158,7 @@ class Bases(AirtableModel):
         permission_level: str
 
 
-class BaseCollaborators(AirtableModel):
+class BaseCollaborators(_Collaborators, url="meta/bases/{base.id}"):
     """
     Detailed information about who can access a base.
 
@@ -61,15 +170,18 @@ class BaseCollaborators(AirtableModel):
     permission_level: str
     workspace_id: str
     interfaces: Dict[str, "BaseCollaborators.InterfaceCollaborators"] = _FD()
-    group_collaborators: Optional["BaseCollaborators.GroupCollaborators"]
-    individual_collaborators: Optional["BaseCollaborators.IndividualCollaborators"]
-    invite_links: Optional["BaseCollaborators.InviteLinks"]
-
-    class InterfaceCollaborators(AirtableModel):
+    group_collaborators: "BaseCollaborators.GroupCollaborators" = _F("BaseCollaborators.GroupCollaborators")  # fmt: skip
+    individual_collaborators: "BaseCollaborators.IndividualCollaborators" = _F("BaseCollaborators.IndividualCollaborators")  # fmt: skip
+    invite_links: "BaseCollaborators.InviteLinks" = _F("BaseCollaborators.InviteLinks")  # fmt: skip
+
+    class InterfaceCollaborators(
+        _Collaborators,
+        url="meta/bases/{base.id}/interfaces/{key}",
+    ):
         created_time: str
         group_collaborators: List["GroupCollaborator"] = _FL()
         individual_collaborators: List["IndividualCollaborator"] = _FL()
-        invite_links: List["InviteLink"] = _FL()
+        invite_links: List["InterfaceInviteLink"] = _FL()
 
     class GroupCollaborators(AirtableModel):
         via_base: List["GroupCollaborator"] = _FL(alias="baseCollaborators")
@@ -77,13 +189,11 @@ class BaseCollaborators(AirtableModel):
 
     class IndividualCollaborators(AirtableModel):
         via_base: List["IndividualCollaborator"] = _FL(alias="baseCollaborators")
-        via_workspace: List["IndividualCollaborator"] = _FL(
-            alias="workspaceCollaborators"
-        )
+        via_workspace: List["IndividualCollaborator"] = _FL(alias="workspaceCollaborators")  # fmt: skip
 
-    class InviteLinks(AirtableModel):
-        base_invite_links: List["InviteLink"] = _FL()
-        workspace_invite_links: List["InviteLink"] = _FL()
+    class InviteLinks(RestfulModel, url="{base_collaborators._url}/invites"):
+        via_base: List["InviteLink"] = _FL(alias="baseInviteLinks")
+        via_workspace: List["WorkspaceInviteLink"] = _FL(alias="workspaceInviteLinks")  # fmt: skip
 
 
 class BaseShares(AirtableModel):
@@ -95,7 +205,13 @@ class BaseShares(AirtableModel):
 
     shares: List["BaseShares.Info"]
 
-    class Info(AirtableModel):
+    class Info(
+        CanUpdateModel,
+        CanDeleteModel,
+        url="meta/bases/{base.id}/shares/{self.share_id}",
+        writable=["state"],
+        reload_after_save=False,
+    ):
         state: str
         created_by_user_id: str
         created_time: str
@@ -107,6 +223,20 @@ class BaseShares(AirtableModel):
         view_id: Optional[str] = None
         effective_email_domain_allow_list: List[str] = _FL()
 
+        def enable(self) -> None:
+            """
+            Enable the base share.
+            """
+            self.state = "enabled"
+            self.save()
+
+        def disable(self) -> None:
+            """
+            Disable the base share.
+            """
+            self.state = "disabled"
+            self.save()
+
 
 class BaseSchema(AirtableModel):
     """
@@ -233,7 +363,21 @@ class IndividualCollaborator(AirtableModel):
     permission_level: str
 
 
-class InviteLink(AirtableModel):
+class BaseIndividualCollaborator(IndividualCollaborator):
+    base_id: str
+
+
+class BaseGroupCollaborator(GroupCollaborator):
+    base_id: str
+
+
+# URL generation for an InviteLink assumes that it is nested within
+# a RestfulModel class named "InviteLink" that provides URL context.
+class InviteLink(CanDeleteModel, url="{invite_links._url}/{self.id}"):
+    """
+    Represents an `invite link <https://airtable.com/developers/web/api/model/invite-link>`__.
+    """
+
     id: str
     type: str
     created_time: str
@@ -243,16 +387,35 @@ class InviteLink(AirtableModel):
     restricted_to_email_domains: List[str] = _FL()
 
 
-class BaseIndividualCollaborator(IndividualCollaborator):
+class BaseInviteLink(
+    InviteLink,
+    url="meta/bases/{self.base_id}/invites/{self.id}",
+):
+    """
+    Represents a `base invite link <https://airtable.com/developers/web/api/model/base-invite-link>`__.
+    """
+
     base_id: str
 
 
-class BaseGroupCollaborator(GroupCollaborator):
-    base_id: str
+class WorkspaceInviteLink(
+    InviteLink,
+    url="meta/workspaces/{base_collaborators.workspace_id}/invites/{self.id}",
+):
+    """
+    Represents an `invite link <https://airtable.com/developers/web/api/model/invite-link>`__
+    to a workspace that was returned within a base schema.
+    """
 
 
-class BaseInviteLink(InviteLink):
-    base_id: str
+class InterfaceInviteLink(
+    InviteLink,
+    url="{interface_collaborators._url}/invites/{self.id}",
+):
+    """
+    Represents an `invite link <https://airtable.com/developers/web/api/model/invite-link>`__
+    to an interface that was returned within a base schema.
+    """
 
 
 class EnterpriseInfo(AirtableModel):
@@ -275,7 +438,7 @@ class EnterpriseInfo(AirtableModel):
         is_sso_required: bool
 
 
-class WorkspaceCollaborators(AirtableModel):
+class WorkspaceCollaborators(_Collaborators, url="meta/workspaces/{self.id}"):
     """
     Detailed information about who can access a workspace.
 
@@ -286,29 +449,33 @@ class WorkspaceCollaborators(AirtableModel):
     name: str
     created_time: str
     base_ids: List[str]
-    # We really don't need black to wrap these lines of text.
-    # fmt: off
-    restrictions: "WorkspaceCollaborators.Restrictions" = pydantic.Field(alias="workspaceRestrictions")
-    group_collaborators: Optional["WorkspaceCollaborators.GroupCollaborators"] = None
-    individual_collaborators: Optional["WorkspaceCollaborators.IndividualCollaborators"] = None
-    invite_links: Optional["WorkspaceCollaborators.InviteLinks"] = None
-    # fmt: on
-
-    class Restrictions(AirtableModel):
+    restrictions: "WorkspaceCollaborators.Restrictions" = pydantic.Field(alias="workspaceRestrictions")  # fmt: skip
+    group_collaborators: "WorkspaceCollaborators.GroupCollaborators" = _F("WorkspaceCollaborators.GroupCollaborators")  # fmt: skip
+    individual_collaborators: "WorkspaceCollaborators.IndividualCollaborators" = _F("WorkspaceCollaborators.IndividualCollaborators")  # fmt: skip
+    invite_links: "WorkspaceCollaborators.InviteLinks" = _F("WorkspaceCollaborators.InviteLinks")  # fmt: skip
+
+    class Restrictions(
+        CanUpdateModel,
+        url="{workspace_collaborators._url}/updateRestrictions",
+        save_method="POST",
+        reload_after_save=False,
+    ):
         invite_creation: str = pydantic.Field(alias="inviteCreationRestriction")
         share_creation: str = pydantic.Field(alias="shareCreationRestriction")
 
     class GroupCollaborators(AirtableModel):
-        base_collaborators: List["BaseGroupCollaborator"]
-        workspace_collaborators: List["GroupCollaborator"]
+        via_base: List["BaseGroupCollaborator"] = _FL(alias="baseCollaborators")
+        via_workspace: List["GroupCollaborator"] = _FL(alias="workspaceCollaborators")
 
     class IndividualCollaborators(AirtableModel):
-        base_collaborators: List["BaseIndividualCollaborator"]
-        workspace_collaborators: List["IndividualCollaborator"]
+        via_base: List["BaseIndividualCollaborator"] = _FL(alias="baseCollaborators")
+        via_workspace: List["IndividualCollaborator"] = _FL(
+            alias="workspaceCollaborators"
+        )
 
-    class InviteLinks(AirtableModel):
-        base_invite_links: List["BaseInviteLink"]
-        workspace_invite_links: List["InviteLink"]
+    class InviteLinks(RestfulModel, url="{workspace_collaborators._url}/invites"):
+        via_base: List["BaseInviteLink"] = _FL(alias="baseInviteLinks")
+        via_workspace: List["InviteLink"] = _FL(alias="workspaceInviteLinks")
 
 
 class NestedId(AirtableModel):
@@ -374,7 +541,12 @@ class Collaborations(AirtableModel):
         permission_level: str
 
 
-class UserInfo(AirtableModel):
+class UserInfo(
+    CanUpdateModel,
+    CanDeleteModel,
+    url="{enterprise.url}/users/{self.id}",
+    writable=["state", "email", "first_name", "last_name"],
+):
     """
     Detailed information about a user.
 
@@ -395,6 +567,9 @@ class UserInfo(AirtableModel):
     groups: List[NestedId] = _FL()
     collaborations: "Collaborations" = pydantic.Field(default_factory=Collaborations)
 
+    def logout(self) -> None:
+        self._api.post(self._url + "/logout")
+
 
 class UserGroup(AirtableModel):
     """
diff --git a/pyairtable/models/webhook.py b/pyairtable/models/webhook.py
index 639d6ce..e8d7cdb 100644
--- a/pyairtable/models/webhook.py
+++ b/pyairtable/models/webhook.py
@@ -147,7 +147,7 @@ class Webhook(CanDeleteModel, url="bases/{base.id}/webhooks/{self.id}"):
         ):
             payloads = page["payloads"]
             for index, payload in enumerate(payloads):
-                payload = WebhookPayload.parse_obj(payload)
+                payload = WebhookPayload.from_api(payload, self._api, context=self)
                 payload.cursor = cursor + index
                 yield payload
                 count += 1


Test patch:
diff --git a/tests/conftest.py b/tests/conftest.py
index 7bd9593..06119da 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -2,14 +2,14 @@ import json
 from collections import OrderedDict
 from pathlib import Path
 from posixpath import join as urljoin
-from typing import Callable
+from typing import Any, Callable
 from urllib.parse import quote, urlencode
 
 import pytest
 from mock import Mock
 from requests import HTTPError
 
-from pyairtable.api import Api, Base, Table
+from pyairtable import Api, Base, Table, Workspace
 
 
 @pytest.fixture
@@ -30,7 +30,9 @@ def url_builder():
 @pytest.fixture
 def constants():
     return dict(
-        API_KEY="FakeApiKey", BASE_ID="appJMY16gZDQrMWpA", TABLE_NAME="Table Name"
+        API_KEY="FakeApiKey",
+        BASE_ID="appLkNDICXNqxSDhG",
+        TABLE_NAME="Table Name",
     )
 
 
@@ -54,6 +56,16 @@ def table(base: Base, constants) -> Table:
     return base.table(constants["TABLE_NAME"])
 
 
+@pytest.fixture
+def workspace_id() -> str:
+    return "wspmhESAta6clCCwF"  # see WorkspaceCollaborators.json
+
+
+@pytest.fixture
+def workspace(api: Api, workspace_id) -> Workspace:
+    return api.workspace(workspace_id)
+
+
 @pytest.fixture
 def mock_records():
     return [
@@ -139,3 +151,30 @@ def sample_json(sample_data: Path) -> Callable:
             return json.load(fp)
 
     return _get_sample_json
+
+
+@pytest.fixture
+def schema_obj(api, sample_json):
+    """
+    Test fixture that provides a callable function which retrieves
+    an object generated from tests/sample_data, and optionally
+    retrieves an attribute of that object.
+    """
+
+    def _get_schema_obj(name: str, *, context: Any = None) -> Any:
+        from pyairtable.models import schema
+
+        obj_name, _, obj_path = name.partition(".")
+        obj_data = sample_json(obj_name)
+        obj_cls = getattr(schema, obj_name)
+
+        if context:
+            obj = obj_cls.from_api(obj_data, api, context=context)
+        else:
+            obj = obj_cls.parse_obj(obj_data)
+
+        if obj_path:
+            obj = eval(f"obj.{obj_path}", None, {"obj": obj})
+        return obj
+
+    return _get_schema_obj
diff --git a/tests/sample_data/BaseCollaborators.json b/tests/sample_data/BaseCollaborators.json
index 414aecf..411b4b5 100644
--- a/tests/sample_data/BaseCollaborators.json
+++ b/tests/sample_data/BaseCollaborators.json
@@ -61,6 +61,43 @@
       }
     ]
   },
+  "interfaces": {
+    "pbdLkNDICXNqxSDhG": {
+      "createdTime": "2024-02-04T02:28:06.000Z",
+      "firstPublishTime": "2024-02-04T02:28:12.000Z",
+      "groupCollaborators": [
+        {
+          "createdTime": "2024-02-04T02:28:20.184Z",
+          "grantedByUserId": "usrL2PNC5o3H4lBEi",
+          "groupId": "ugpR8ZT9KtIgp8Bh3",
+          "name": "Test Interface",
+          "permissionLevel": "read"
+        }
+      ],
+      "id": "pbdLkNDICXNqxSDhG",
+      "individualCollaborators": [
+        {
+          "createdTime": "2024-02-04T04:00:00.749Z",
+          "email": "test@example.com",
+          "grantedByUserId": "usrL2PNC5o3H4lBEi",
+          "permissionLevel": "edit",
+          "userId": "usrR8ZT9KtIgp8Bh3"
+        }
+      ],
+      "inviteLinks": [
+        {
+          "createdTime": "2019-01-03T12:33:12.421Z",
+          "id": "invJiqaXmPqq6ABCD",
+          "invitedEmail": "bam@bam.com",
+          "permissionLevel": "edit",
+          "referredByUserId": "usrL2PNC5o3H4lBEi",
+          "restrictedToEmailDomains": [],
+          "type": "singleUse"
+        }
+      ],
+      "name": "Interface"
+    }
+  },
   "inviteLinks": {
     "baseInviteLinks": [
       {
@@ -78,7 +115,7 @@
     "workspaceInviteLinks": [
       {
         "createdTime": "2019-01-03T12:33:12.421Z",
-        "id": "invJiqaXmPqq6Ec87",
+        "id": "invJiqaXmPqq6Ec99",
         "invitedEmail": "bam@bam.com",
         "permissionLevel": "edit",
         "referredByUserId": "usrL2PNC5o3H4lBEi",
diff --git a/tests/sample_data/UserRemoved.json b/tests/sample_data/UserRemoved.json
new file mode 100644
index 0000000..006bea4
--- /dev/null
+++ b/tests/sample_data/UserRemoved.json
@@ -0,0 +1,40 @@
+{
+  "shared": {
+    "workspaces": [
+      {
+        "permissionLevel": "owner",
+        "userId": "usrL2PNC5o3H4lBEi",
+        "workspaceId": "wsp00000000000000",
+        "workspaceName": "Workspace name"
+      }
+    ]
+  },
+  "unshared": {
+    "bases": [
+      {
+        "baseId": "app00000000000000",
+        "baseName": "Base name",
+        "formerPermissionLevel": "create",
+        "userId": "usr00000000000000"
+      }
+    ],
+    "interfaces": [
+      {
+        "baseId": "app00000000000000",
+        "formerPermissionLevel": "create",
+        "interfaceId": "pgb00000000000000",
+        "interfaceName": "Interface name",
+        "userId": "usr00000000000000"
+      }
+    ],
+    "workspaces": [
+      {
+        "formerPermissionLevel": "owner",
+        "userId": "usr00000000000000",
+        "workspaceId": "wsp00000000000000",
+        "workspaceName": "Workspace name"
+      }
+    ]
+  },
+  "wasUserRemovedAsAdmin": true
+}
diff --git a/tests/sample_data/WorkspaceCollaborators.json b/tests/sample_data/WorkspaceCollaborators.json
index ca14986..3d20801 100644
--- a/tests/sample_data/WorkspaceCollaborators.json
+++ b/tests/sample_data/WorkspaceCollaborators.json
@@ -71,9 +71,9 @@
   "inviteLinks": {
     "baseInviteLinks": [
       {
-        "baseId": "appSW9R5uCNmRmfl6",
+        "baseId": "appLkNDICXNqxSDhG",
         "createdTime": "2019-01-03T12:33:12.421Z",
-        "id": "invJiqaXmPqq6Ec87",
+        "id": "invJiqaXmPqqAPP99",
         "invitedEmail": null,
         "permissionLevel": "read",
         "referredByUserId": "usrsOEchC9xuwRgKk",
@@ -84,7 +84,7 @@
     "workspaceInviteLinks": [
       {
         "createdTime": "2019-01-03T12:33:12.421Z",
-        "id": "invJiqaXmPqq6Ec87",
+        "id": "invJiqaXmPqqWSP00",
         "invitedEmail": "bam@bam.com",
         "permissionLevel": "owner",
         "referredByUserId": "usrL2PNC5o3H4lBEi",
diff --git a/tests/test_api_base.py b/tests/test_api_base.py
index 4223680..83432f4 100644
--- a/tests/test_api_base.py
+++ b/tests/test_api_base.py
@@ -59,7 +59,7 @@ def test_repr(api, kwargs, expected):
 
 
 def test_url(base):
-    assert base.url == "https://api.airtable.com/v0/appJMY16gZDQrMWpA"
+    assert base.url == "https://api.airtable.com/v0/appLkNDICXNqxSDhG"
 
 
 def test_schema(base: Base, mock_tables_endpoint):
diff --git a/tests/test_api_enterprise.py b/tests/test_api_enterprise.py
index 61ea163..c435bb2 100644
--- a/tests/test_api_enterprise.py
+++ b/tests/test_api_enterprise.py
@@ -3,7 +3,11 @@ from unittest.mock import Mock, call, patch
 
 import pytest
 
-from pyairtable.api.enterprise import Enterprise
+from pyairtable.api.enterprise import (
+    ClaimUsersResponse,
+    DeleteUsersResponse,
+    Enterprise,
+)
 from pyairtable.models.schema import EnterpriseInfo, UserGroup, UserInfo
 from pyairtable.testing import fake_id
 
@@ -26,6 +30,9 @@ def enterprise_mocks(enterprise, requests_mock, sample_json):
     m.user_id = m.json_user["id"]
     m.group_id = m.json_group["id"]
     m.get_info = requests_mock.get(enterprise.url, json=sample_json("EnterpriseInfo"))
+    m.get_user = requests_mock.get(
+        f"{enterprise.url}/users/{m.user_id}", json=m.json_user
+    )
     m.get_users = requests_mock.get(f"{enterprise.url}/users", json=m.json_users)
     m.get_group = requests_mock.get(
         enterprise.api.build_url(f"meta/groups/{m.json_group['id']}"),
@@ -47,6 +54,14 @@ def enterprise_mocks(enterprise, requests_mock, sample_json):
             for n in range(N_AUDIT_PAGES)
         ],
     )
+    m.remove_user = requests_mock.post(
+        enterprise.url + f"/users/{m.user_id}/remove",
+        json=sample_json("UserRemoved"),
+    )
+    m.claim_users = requests_mock.post(
+        enterprise.url + "/users/claim",
+        json={"errors": []},
+    )
     return m
 
 
@@ -210,3 +225,88 @@ def test_audit_log__sortorder(
     request = enterprise_mocks.get_audit_log.last_request
     assert request.qs["sortorder"] == [sortorder]
     assert m.mock_calls[-1].kwargs["offset_field"] == offset_field
+
+
+@pytest.mark.parametrize(
+    "kwargs,expected",
+    [
+        (
+            {},
+            {"isDryRun": False},
+        ),
+        (
+            {"replacement": "otherUser"},
+            {"isDryRun": False, "replacementOwnerId": "otherUser"},
+        ),
+    ],
+)
+def test_remove_user(enterprise, enterprise_mocks, kwargs, expected):
+    removed = enterprise.remove_user(enterprise_mocks.user_id, **kwargs)
+    assert enterprise_mocks.remove_user.call_count == 1
+    assert enterprise_mocks.remove_user.last_request.json() == expected
+    assert removed.shared.workspaces[0].user_id == "usrL2PNC5o3H4lBEi"
+
+
+@pytest.fixture
+def user_info(enterprise, enterprise_mocks):
+    user_info = enterprise.user(enterprise_mocks.user_id)
+    assert user_info._url == f"{enterprise.url}/users/{user_info.id}"
+    return user_info
+
+
+def test_delete_user(user_info, requests_mock):
+    m = requests_mock.delete(user_info._url)
+    user_info.delete()
+    assert m.call_count == 1
+
+
+def test_manage_user(user_info, requests_mock):
+    m = requests_mock.patch(user_info._url)
+    user_info.save()
+    assert m.call_count == 1
+    assert m.last_request.json() == {"email": "foo@bar.com", "state": "provisioned"}
+
+
+def test_logout_user(user_info, requests_mock):
+    m = requests_mock.post(user_info._url + "/logout")
+    user_info.logout()
+    assert m.call_count == 1
+    assert m.last_request.body is None
+
+
+def test_claim_users(enterprise, enterprise_mocks):
+    result = enterprise.claim_users(
+        {
+            "usrFakeUserId": "managed",
+            "someone@example.com": "unmanaged",
+        }
+    )
+    assert isinstance(result, ClaimUsersResponse)
+    assert enterprise_mocks.claim_users.call_count == 1
+    assert enterprise_mocks.claim_users.last_request.json() == {
+        "users": [
+            {"id": "usrFakeUserId", "state": "managed"},
+            {"email": "someone@example.com", "state": "unmanaged"},
+        ]
+    }
+
+
+def test_delete_users(enterprise, requests_mock):
+    response = {
+        "deletedUsers": [{"email": "foo@bar.com", "id": "usrL2PNC5o3H4lBEi"}],
+        "errors": [
+            {
+                "email": "bar@bam.com",
+                "message": "Invalid permissions",
+                "type": "INVALID_PERMISSIONS",
+            }
+        ],
+    }
+    emails = [f"foo{n}@bar.com" for n in range(5)]
+    m = requests_mock.delete(enterprise.url + "/users", json=response)
+    parsed = enterprise.delete_users(emails)
+    assert m.call_count == 1
+    assert m.last_request.qs == {"email": emails}
+    assert isinstance(parsed, DeleteUsersResponse)
+    assert parsed.deleted_users[0].email == "foo@bar.com"
+    assert parsed.errors[0].type == "INVALID_PERMISSIONS"
diff --git a/tests/test_api_table.py b/tests/test_api_table.py
index 95d4116..6fa43c6 100644
--- a/tests/test_api_table.py
+++ b/tests/test_api_table.py
@@ -11,7 +11,7 @@ from pyairtable.utils import chunked
 
 
 @pytest.fixture()
-def table_schema(sample_json) -> TableSchema:
+def table_schema(sample_json, api, base) -> TableSchema:
     return TableSchema.parse_obj(sample_json("TableSchema"))
 
 
@@ -78,7 +78,7 @@ def test_invalid_constructor(api, base):
 
 
 def test_repr(table: Table):
-    assert repr(table) == "<Table base='appJMY16gZDQrMWpA' name='Table Name'>"
+    assert repr(table) == "<Table base='appLkNDICXNqxSDhG' name='Table Name'>"
 
 
 def test_schema(base, requests_mock, sample_json):
diff --git a/tests/test_models.py b/tests/test_models.py
index 5a6a885..52e5256 100644
--- a/tests/test_models.py
+++ b/tests/test_models.py
@@ -1,9 +1,12 @@
+from typing import List
+
 import pytest
 
 from pyairtable.models._base import (
     AirtableModel,
     CanDeleteModel,
     CanUpdateModel,
+    RestfulModel,
     update_forward_refs,
 )
 
@@ -36,16 +39,29 @@ def create_instance(api, raw_data):
     return _creates_instance
 
 
-def test_raw(raw_data):
+def test_raw(api):
     """
-    Test that AirtableModel.parse_obj saves the raw value, so that developers
+    Test that AirtableModel.from_api saves the raw value, so that developers
     can access the exact payload we received from the API. This is mostly
-    in case Airtable adds new things to webhooks or webhook payloads in the future.
+    in case Airtable adds new things to webhooks or schemas in the future.
     """
-    obj = AirtableModel.parse_obj(raw_data)
+
+    class Grandchild(AirtableModel):
+        value: int
+
+    class Child(AirtableModel):
+        grandchild: Grandchild
+
+    class Parent(AirtableModel):
+        child: Child
+
+    raw = {"child": {"grandchild": {"value": 1}}, "foo": "FOO", "bar": "BAR"}
+    obj = Parent.from_api(raw, api)
     assert not hasattr(obj, "foo")
     assert not hasattr(obj, "bar")
-    assert obj._raw == raw_data
+    assert obj._raw == raw
+    assert obj.child._raw == raw["child"]
+    assert obj.child.grandchild._raw == raw["child"]["grandchild"]
 
 
 @pytest.mark.parametrize("prefix", ["https://api.airtable.com/v0/prefix", "prefix"])
@@ -100,6 +116,70 @@ def test_save_without_url(create_instance):
         obj.save()
 
 
+def test_save__nested_reload(requests_mock, api):
+    """
+    Test that reloading an object with nested models correctly reloads all of them,
+    while preserving those nested models' access to the API.
+    """
+
+    class Parent(CanUpdateModel, url="foo/{self.id}"):
+        id: int
+        name: str
+        children: List["Parent.Child"]  # noqa
+
+        class Child(CanUpdateModel, url="foo/{parent.id}/child/{child.id}"):
+            id: int
+            name: str
+
+    update_forward_refs(Parent)
+
+    parent_data = {
+        "id": 1,
+        "name": "One",
+        "children": [
+            (child2_data := {"id": 2, "name": "Two"}),
+            (child3_data := {"id": 3, "name": "Three"}),
+        ],
+    }
+    requests_mock.get(parent_url := api.build_url("foo/1"), json=parent_data)
+    requests_mock.get(child2_url := api.build_url("foo/1/child/2"), json=child2_data)
+    requests_mock.get(child3_url := api.build_url("foo/1/child/3"), json=child3_data)
+
+    parent = Parent.from_api(parent_data, api)
+    assert parent.name == "One"
+    assert parent.children[0].name == "Two"
+
+    # Test that we can still reload the parent object
+    m_parent_patch = requests_mock.patch(
+        parent_url,
+        json={
+            **parent_data,
+            "name": (parent_update := "One Updated"),
+        },
+    )
+    parent.name = parent_update
+    parent.save()
+    assert m_parent_patch.call_count == 1
+    assert m_parent_patch.last_request.json()["name"] == parent_update
+
+    # Test that we can still patch a nested object after its parent was reloaded,
+    # because we saved the URL context from `from_api()` and reused it on `_reload()`.
+    m_child2_patch = requests_mock.patch(child2_url, json=child2_data)
+    m_child3_patch = requests_mock.patch(
+        child3_url,
+        json={
+            **child3_data,
+            "name": (child3_update := "Three Updated"),
+        },
+    )
+    parent.children[1].name = child3_update
+    parent.children[1].save()
+    assert m_child3_patch.call_count == 1
+    assert m_child3_patch.last_request.json()["name"] == child3_update
+    assert parent.children[1].name == child3_update
+    assert m_child2_patch.call_count == 0  # just to be sure
+
+
 def test_delete(requests_mock, create_instance):
     obj = create_instance()
     m = requests_mock.delete(obj._url)
@@ -168,3 +248,33 @@ def test_update_forward_refs():
 
     # This will cause RecursionError if we're not careful
     update_forward_refs(Outer)
+
+
+def test_restfulmodel__set_url(api, base):
+    """
+    Test that the RestfulModel class generates a URL based on API context.
+    Also test that RestfulModel puts the full URL context into certain types
+    of exceptions that occur during URL formatting.
+    """
+
+    class Dummy(RestfulModel, url="{base.id}/{dummy.one}/{dummy.two}"):
+        one: int
+        two: str
+
+    data = {"one": 1, "two": "2"}
+
+    d = Dummy.from_api(data, api, context={"base": base})
+    assert d._url == api.build_url(f"{base.id}/1/2")
+
+    with pytest.raises(KeyError) as exc_info:
+        Dummy.from_api(data, api)
+
+    assert exc_info.match(r"\('base', \{'dummy': .*\}\)")
+
+    with pytest.raises(AttributeError) as exc_info:
+        Dummy.from_api(data, api, context={"base": None})
+
+    assert exc_info.match(
+        r'"\'NoneType\' object has no attribute \'id\'"'
+        r", \{'base': None, 'dummy': Dummy\(.*\)\}"
+    )
diff --git a/tests/test_models_schema.py b/tests/test_models_schema.py
index c631191..ad9a23c 100644
--- a/tests/test_models_schema.py
+++ b/tests/test_models_schema.py
@@ -1,10 +1,28 @@
-from operator import attrgetter, itemgetter
+from operator import attrgetter
 from typing import List, Optional
 
+import mock
 import pytest
 
-import pyairtable.models.schema
+from pyairtable.models import schema
 from pyairtable.models._base import AirtableModel
+from pyairtable.testing import fake_id
+
+
+@pytest.fixture
+def mock_base_metadata(base, sample_json, requests_mock):
+    base_json = sample_json("BaseCollaborators")
+    requests_mock.get(base.meta_url(), json=base_json)
+    requests_mock.get(base.meta_url("tables"), json=sample_json("BaseSchema"))
+    requests_mock.get(base.meta_url("shares"), json=sample_json("BaseShares"))
+    for pbd_id, pbd_json in base_json["interfaces"].items():
+        requests_mock.get(base.meta_url("interfaces", pbd_id), json=pbd_json)
+
+
+@pytest.fixture
+def mock_workspace_metadata(workspace, sample_json, requests_mock):
+    workspace_json = sample_json("WorkspaceCollaborators")
+    requests_mock.get(workspace.url, json=workspace_json)
 
 
 @pytest.mark.parametrize(
@@ -18,11 +36,11 @@ from pyairtable.models._base import AirtableModel
     ],
 )
 def test_parse(sample_json, clsname):
-    cls = attrgetter(clsname)(pyairtable.models.schema)
+    cls = attrgetter(clsname)(schema)
     cls.parse_obj(sample_json(clsname))
 
 
-@pytest.mark.parametrize("cls", pyairtable.models.schema.FieldSchema.__args__)
+@pytest.mark.parametrize("cls", schema.FieldSchema.__args__)
 def test_parse_field(sample_json, cls):
     cls.parse_obj(sample_json("field_schema/" + cls.__name__))
 
@@ -41,13 +59,13 @@ def test_parse_field(sample_json, cls):
     ],
 )
 def test_find_in_collection(clsname, method, id_or_name, sample_json):
-    cls = attrgetter(clsname)(pyairtable.models.schema)
+    cls = attrgetter(clsname)(schema)
     obj = cls.parse_obj(sample_json(clsname))
     assert getattr(obj, method)(id_or_name)
 
 
 @pytest.mark.parametrize(
-    "test_case",
+    "obj_path, expected_value",
     {
         "BaseCollaborators.individual_collaborators.via_base[0].permission_level": "create",
         "BaseCollaborators.individual_collaborators.via_base[0].user_id": "usrsOEchC9xuwRgKk",
@@ -66,21 +84,15 @@ def test_find_in_collection(clsname, method, id_or_name, sample_json):
         "UserInfo.is_two_factor_auth_enabled": False,
         "UserInfo.name": "foo baz",
         "WorkspaceCollaborators.base_ids": ["appLkNDICXNqxSDhG", "appSW9R5uCNmRmfl6"],
-        "WorkspaceCollaborators.invite_links.base_invite_links[0].id": "invJiqaXmPqq6Ec87",
+        "WorkspaceCollaborators.invite_links.via_base[0].id": "invJiqaXmPqqAPP99",
     }.items(),
-    ids=itemgetter(0),
 )
-def test_deserialized_values(test_case, sample_json):
+def test_deserialized_values(obj_path, expected_value, schema_obj):
     """
     Spot check that certain values get loaded correctly from JSON into Python.
     This is not intended to be comprehensive, just another chance to catch regressions.
     """
-    clsname_attr, expected = test_case
-    clsname = clsname_attr.split(".")[0]
-    cls = attrgetter(clsname)(pyairtable.models.schema)
-    obj = cls.parse_obj(sample_json(clsname))
-    val = eval(clsname_attr, None, {clsname: obj})
-    assert val == expected
+    assert schema_obj(obj_path) == expected_value
 
 
 class Outer(AirtableModel):
@@ -92,7 +104,7 @@ class Outer(AirtableModel):
         deleted: Optional[bool] = None
 
     def find(self, id_or_name):
-        return pyairtable.models.schema._find(self.inners, id_or_name)
+        return schema._find(self.inners, id_or_name)
 
 
 def test_find():
@@ -116,3 +128,326 @@ def test_find():
         collection.find("0003")
     with pytest.raises(KeyError):
         collection.find("0004")
+
+
+@pytest.mark.parametrize(
+    "kind,id",
+    [
+        ("user", "usrsOEchC9xuwRgKk"),
+        ("group", "ugpR8ZT9KtIgp8Bh3"),
+    ],
+)
+def test_base_collaborators__add(
+    base, kind, id, requests_mock, sample_json, mock_base_metadata
+):
+    """
+    Test that we can call base.collaborators().add_{user,group}
+    to grant access to the base.
+    """
+    m = requests_mock.post(base.meta_url("collaborators"), body="")
+    method = getattr(base.collaborators(), f"add_{kind}")
+    method(id, "read")
+    assert m.call_count == 1
+    assert m.last_request.json() == {
+        "collaborators": [{kind: {"id": id}, "permissionLevel": "read"}]
+    }
+
+
+@pytest.mark.parametrize(
+    "kind,id",
+    [
+        ("user", "usrsOEchC9xuwRgKk"),
+        ("group", "ugpR8ZT9KtIgp8Bh3"),
+    ],
+)
+def test_workspace_collaborators__add(api, kind, id, requests_mock, sample_json):
+    """
+    Test that we can call workspace.collaborators().add_{user,group}
+    to grant access to the workspace.
+    """
+    workspace_json = sample_json("WorkspaceCollaborators")
+    workspace = api.workspace(workspace_json["id"])
+    requests_mock.get(workspace.url, json=workspace_json)
+    m = requests_mock.post(f"{workspace.url}/collaborators", body="")
+    method = getattr(workspace.collaborators(), f"add_{kind}")
+    method(id, "read")
+    assert m.call_count == 1
+    assert m.last_request.json() == {
+        "collaborators": [{kind: {"id": id}, "permissionLevel": "read"}]
+    }
+
+
+@pytest.mark.parametrize(
+    "name,id",
+    [
+        ("base", "appLkNDICXNqxSDhG"),
+        ("workspace", "wspmhESAta6clCCwF"),
+    ],
+)
+def test_update_collaborator(api, name, id, requests_mock, sample_json):
+    """
+    Test that we can call collaborators().update() to change the permission level
+    of a user or group on a base or workspace.
+    """
+    target = getattr(api, name)(id)
+    grp = fake_id("grp")
+    obj = sample_json(f"{name.capitalize()}Collaborators")
+    requests_mock.get(api.build_url(f"meta/{name}s/{id}"), json=obj)
+    m = requests_mock.patch(api.build_url(f"meta/{name}s/{id}/collaborators/{grp}"))
+    target.collaborators().update(grp, "read")
+    assert m.call_count == 1
+    assert m.last_request.json() == {"permissionLevel": "read"}
+
+
+@pytest.mark.parametrize(
+    "name,id",
+    [
+        ("base", "appLkNDICXNqxSDhG"),
+        ("workspace", "wspmhESAta6clCCwF"),
+    ],
+)
+def test_remove_collaborator(api, name, id, requests_mock, sample_json):
+    """
+    Test that we can call collaborators().remove() to revoke permissions
+    from a user or group to a base or workspace.
+    """
+    target = getattr(api, name)(id)
+    grp = fake_id("grp")
+    obj = sample_json(f"{name.capitalize()}Collaborators")
+    requests_mock.get(api.build_url(f"meta/{name}s/{id}"), json=obj)
+    m = requests_mock.delete(api.build_url(f"meta/{name}s/{id}/collaborators/{grp}"))
+    target.collaborators().remove(grp)
+    assert m.call_count == 1
+    assert m.last_request.body is None
+
+
+def test_invite_link__delete(
+    base,
+    workspace,
+    requests_mock,
+    mock_base_metadata,
+    mock_workspace_metadata,
+):
+    """
+    Test that we can revoke an invite link.
+    """
+    for invite_link in [
+        base.collaborators().invite_links.via_base[0],
+        base.collaborators().invite_links.via_workspace[0],
+        base.collaborators().interfaces["pbdLkNDICXNqxSDhG"].invite_links[0],
+        workspace.collaborators().invite_links.via_base[0],
+        workspace.collaborators().invite_links.via_workspace[0],
+    ]:
+        endpoint = requests_mock.delete(invite_link._url)
+        invite_link.delete()
+        assert endpoint.call_count == 1
+
+
+@pytest.fixture
+def interface_url(base):
+    return base.meta_url("interfaces", "pbdLkNDICXNqxSDhG")
+
+
+@pytest.mark.parametrize("kind", ("user", "group"))
+def test_add_interface_collaborator(
+    base, kind, requests_mock, interface_url, mock_base_metadata
+):
+    m = requests_mock.post(f"{interface_url}/collaborators", body="")
+    interface_schema = base.collaborators().interfaces["pbdLkNDICXNqxSDhG"]
+    method = getattr(interface_schema, f"add_{kind}")
+    method("testObjectId", "read")
+    assert m.call_count == 1
+    assert m.last_request.json() == {
+        "collaborators": [
+            {
+                kind: {"id": "testObjectId"},
+                "permissionLevel": "read",
+            }
+        ]
+    }
+
+
+def test_update_interface_collaborator(
+    base, interface_url, requests_mock, mock_base_metadata
+):
+    m = requests_mock.patch(f"{interface_url}/collaborators/testObjectId")
+    interface_schema = base.collaborators().interfaces["pbdLkNDICXNqxSDhG"]
+    interface_schema.update("testObjectId", "read")
+    assert m.call_count == 1
+    assert m.last_request.json() == {"permissionLevel": "read"}
+
+
+def test_remove_interface_collaborator(
+    base, interface_url, requests_mock, mock_base_metadata
+):
+    m = requests_mock.delete(f"{interface_url}/collaborators/testObjectId")
+    interface_schema = base.collaborators().interfaces["pbdLkNDICXNqxSDhG"]
+    interface_schema.remove("testObjectId")
+    assert m.call_count == 1
+    assert m.last_request.body is None
+
+
+@pytest.mark.parametrize(
+    "target_path",
+    (
+        "BaseCollaborators",
+        "WorkspaceCollaborators",
+        "BaseCollaborators.interfaces['pbdLkNDICXNqxSDhG']",
+    ),
+)
+@pytest.mark.parametrize("kind", ("user", "group"))
+def test_add_collaborator(
+    target_path,
+    kind,
+    schema_obj,
+    requests_mock,  # unused; ensures no network traffic
+):
+    target = schema_obj(target_path)
+    with mock.patch.object(target.__class__, "add_collaborators") as m:
+        target.add(kind, "testId", "read")
+        m.assert_called_once_with([{kind: {"id": "testId"}, "permissionLevel": "read"}])
+
+
+@pytest.mark.parametrize(
+    "target_path",
+    (
+        "BaseCollaborators",
+        "WorkspaceCollaborators",
+        "BaseCollaborators.interfaces['pbdLkNDICXNqxSDhG']",
+    ),
+)
+def test_add_collaborator__invalid_kind(
+    target_path,
+    schema_obj,
+    requests_mock,  # unused; ensures no network traffic
+):
+    target = schema_obj(target_path)
+    with mock.patch.object(target.__class__, "add_collaborators") as m:
+        with pytest.raises(ValueError):
+            target.add("asdf", "testId", "read")
+        assert m.call_count == 0
+
+
+@pytest.mark.parametrize(
+    "target_path",
+    (
+        "BaseCollaborators",
+        "WorkspaceCollaborators",
+        "BaseCollaborators.interfaces['pbdLkNDICXNqxSDhG']",
+    ),
+)
+def test_add_collaborators(
+    target_path,
+    schema_obj,
+    base,
+    workspace,
+    requests_mock,
+):
+    target = schema_obj(target_path, context={"base": base, "workspace": workspace})
+    requests_mock.get(target._url, json=target._raw)
+    m = requests_mock.post(target._url + "/collaborators")
+    target.add_collaborators([1, 2, 3, 4])
+    assert m.call_count == 1
+    assert m.last_request.json() == {"collaborators": [1, 2, 3, 4]}
+
+
+@pytest.mark.parametrize(
+    "expr,expected_url",
+    [
+        (
+            "base.collaborators()",
+            "meta/bases/appLkNDICXNqxSDhG",
+        ),
+        (
+            "base.collaborators().interfaces['pbdLkNDICXNqxSDhG']",
+            "meta/bases/appLkNDICXNqxSDhG/interfaces/pbdLkNDICXNqxSDhG",
+        ),
+        (
+            "base.collaborators().invite_links.via_base[0]",
+            "meta/bases/appLkNDICXNqxSDhG/invites/invJiqaXmPqq6Ec87",
+        ),
+        (
+            "base.collaborators().invite_links.via_workspace[0]",
+            "meta/workspaces/wspmhESAta6clCCwF/invites/invJiqaXmPqq6Ec99",
+        ),
+        (
+            "base.collaborators().interfaces['pbdLkNDICXNqxSDhG'].invite_links[0]",
+            "meta/bases/appLkNDICXNqxSDhG/interfaces/pbdLkNDICXNqxSDhG/invites/invJiqaXmPqq6ABCD",
+        ),
+        (
+            "workspace.collaborators().invite_links.via_base[0]",
+            "meta/bases/appLkNDICXNqxSDhG/invites/invJiqaXmPqqAPP99",
+        ),
+        (
+            "workspace.collaborators().invite_links.via_workspace[0]",
+            "meta/workspaces/wspmhESAta6clCCwF/invites/invJiqaXmPqqWSP00",
+        ),
+        (
+            "table.schema()",
+            "meta/bases/appLkNDICXNqxSDhG/tables/tbltp8DGLhqbUmjK1",
+        ),
+        (
+            "table.schema().field('fld1VnoyuotSTyxW1')",
+            "meta/bases/appLkNDICXNqxSDhG/tables/tbltp8DGLhqbUmjK1/fields/fld1VnoyuotSTyxW1",
+        ),
+        (
+            "table.schema().view('viwQpsuEDqHFqegkp')",
+            "meta/bases/appLkNDICXNqxSDhG/views/viwQpsuEDqHFqegkp",
+        ),
+    ],
+)
+def test_restful_urls(
+    expr,
+    expected_url,
+    api,
+    base,
+    workspace,
+    mock_base_metadata,  # unused; ensures no network traffic
+    mock_workspace_metadata,  # unused; ensures no network traffic
+):
+    """
+    Test that the URLs for RestfulModels are generated correctly.
+    """
+    table = base.table("tbltp8DGLhqbUmjK1")
+    obj = eval(expr, None, {"base": base, "table": table, "workspace": workspace})
+    assert obj._url == api.build_url(expected_url)
+
+
+@pytest.fixture
+def base_share(base, mock_base_metadata) -> schema.BaseShares.Info:
+    return base.shares()[0]
+
+
+def test_share__enable(base_share, requests_mock):
+    m = requests_mock.patch(base_share._url)
+    base_share.enable()
+    assert m.call_count == 1
+    assert m.last_request.json() == {"state": "enabled"}
+
+
+def test_share__disable(base_share, requests_mock):
+    m = requests_mock.patch(base_share._url)
+    base_share.disable()
+    assert m.call_count == 1
+    assert m.last_request.json() == {"state": "disabled"}
+
+
+def test_share__delete(base_share, requests_mock):
+    m = requests_mock.delete(base_share._url)
+    base_share.delete()
+    assert m.call_count == 1
+    assert m.last_request.body is None
+
+
+def test_workspace_restrictions(workspace, mock_workspace_metadata, requests_mock):
+    restrictions = workspace.collaborators().restrictions
+    restrictions.invite_creation = "unrestricted"
+    restrictions.share_creation = "onlyOwners"
+
+    m = requests_mock.post(restrictions._url)
+    restrictions.save()
+    assert m.call_count == 1
+    assert m.last_request.json() == {
+        "inviteCreationRestriction": "unrestricted",
+        "shareCreationRestriction": "onlyOwners",
+    }