From 483f6754a305bf60379d6a54782c6913fcefbd49 Mon Sep 17 00:00:00 2001 From: "Stephen C. Pope" Date: Mon, 21 Oct 2024 10:35:32 -0600 Subject: [PATCH] [Core-551] Client: Add methods to test read/write/ownership of catalog objects (#12711) GitOrigin-RevId: bbadc84268bf6fdd67455fda2fad30aa8120e7ea --- descarteslabs/auth/auth.py | 148 ++++++++++++++++- descarteslabs/auth/tests/test_auth.py | 62 ++++++++ descarteslabs/core/catalog/__init__.py | 2 + descarteslabs/core/catalog/blob.py | 76 ++------- descarteslabs/core/catalog/catalog_base.py | 149 +++++++++++++++++- .../core/catalog/event_api_destination.py | 62 +------- descarteslabs/core/catalog/event_rule.py | 60 +------ descarteslabs/core/catalog/event_schedule.py | 61 +------ .../core/catalog/event_subscription.py | 60 +------ descarteslabs/core/catalog/image_upload.py | 18 ++- descarteslabs/core/catalog/product.py | 61 +------ descarteslabs/core/catalog/tests/base.py | 2 + .../core/catalog/tests/test_catalog_base.py | 68 +++++++- 13 files changed, 460 insertions(+), 369 deletions(-) diff --git a/descarteslabs/auth/auth.py b/descarteslabs/auth/auth.py index fd44d2ae..513b7720 100644 --- a/descarteslabs/auth/auth.py +++ b/descarteslabs/auth/auth.py @@ -153,6 +153,28 @@ class Auth: KEY_JWT_TOKEN = "jwt_token" KEY_ALT_JWT_TOKEN = "JWT_TOKEN" + # The various prefixes that can be used in Catalog ACLs. + ACL_PREFIX_USER = "user:" # Followed by the user's sha1 hash + ACL_PREFIX_EMAIL = "email:" # Followed by the user's email + ACL_PREFIX_GROUP = "group:" # Followed by a lowercase group + ACL_PREFIX_ORG = "org:" # Followed by a lowercase org name + ACL_PREFIX_ACCESS = "access-id:" # Followed by the purchase-specific access id + # Note that the access-id, including the prefix `access_id:`, is matched against + # a group with the same name. In other words `group:access-id:` will + # match against `access-id:` (assuming the `` is identical). + + # these match the values in descarteslabs/common/services/python_auth/groups.py + ORG_ADMIN_SUFFIX = ":org-admin" + RESOURCE_ADMIN_SUFFIX = ":resource-admin" + + # These are cache keys for caching various data in the object's __dict__. + # These are scrubbed out with `_clear_cache()` when retrieving a new token. + KEY_PAYLOAD = "_payload" + KEY_ALL_ACL_SUBJECTS = "_aas" + KEY_ALL_ACL_SUBJECTS_AS_SET = "_aasas" + KEY_ALL_OWNER_ACL_SUBJECTS = "_aoas" + KEY_ALL_OWNER_ACL_SUBJECTS_AS_SET = "_aoasas" + __attrs__ = [ "domain", "scope", @@ -585,7 +607,13 @@ def payload(self): OauthError Raised when a token cannot be obtained or refreshed. """ - return self._get_payload(self.token) + payload = self.__dict__.get(self.KEY_PAYLOAD) + + if payload is None: + payload = self._get_payload(self.token) + self.__dict__[self.KEY_PAYLOAD] = payload + + return payload @staticmethod def _get_payload(token): @@ -754,6 +782,9 @@ def _get_token(self, timeout=100): else: raise OauthError("Could not retrieve token") + # clear out payload and subjects cache + self._clear_cache() + token_info = {} # Read the token from the token_info_path, and save it again @@ -797,6 +828,121 @@ def namespace(self): self._namespace = sha1(self.payload["sub"].encode("utf-8")).hexdigest() return self._namespace + @property + def all_acl_subjects(self): + """ + A list of all ACL subjects identifying this user (the user itself, the org, the + groups) which can be used in ACL queries. + """ + subjects = self.__dict__.get(self.KEY_ALL_ACL_SUBJECTS) + + if subjects is None: + subjects = [self.ACL_PREFIX_USER + self.namespace] + + if email := self.payload.get("email"): + subjects.append(self.ACL_PREFIX_EMAIL + email.lower()) + + if org := self.payload.get("org"): + subjects.append(self.ACL_PREFIX_ORG + org) + + subjects += [ + self.ACL_PREFIX_GROUP + group for group in self._active_groups() + ] + self.__dict__[self.KEY_ALL_ACL_SUBJECTS] = subjects + + return subjects + + @property + def all_acl_subjects_as_set(self): + subjects_as_set = self.__dict__.get(self.KEY_ALL_ACL_SUBJECTS_AS_SET) + + if subjects_as_set is None: + subjects_as_set = set(self.all_acl_subjects) + self.__dict__[self.KEY_ALL_ACL_SUBJECTS_AS_SET] = subjects_as_set + + return subjects_as_set + + @property + def all_owner_acl_subjects(self): + """ + A list of ACL subjects identifying this user (the user itself, the org, + org admin and catalog admins) which can be used in owner ACL queries. + """ + subjects = self.__dict__.get(self.KEY_ALL_OWNER_ACL_SUBJECTS) + + if subjects is None: + subjects = [self.ACL_PREFIX_USER + self.namespace] + + subjects.extend( + [self.ACL_PREFIX_ORG + org for org in self.get_org_admins() if org] + ) + subjects.extend( + [ + self.ACL_PREFIX_ACCESS + access_id + for access_id in self.get_resource_admins() + if access_id + ] + ) + self.__dict__[self.KEY_ALL_OWNER_ACL_SUBJECTS] = subjects + + return subjects + + @property + def all_owner_acl_subjects_as_set(self): + subjects_as_set = self.__dict__.get(self.KEY_ALL_OWNER_ACL_SUBJECTS_AS_SET) + + if subjects_as_set is None: + subjects_as_set = set(self.all_owner_acl_subjects) + self.__dict__[self.KEY_ALL_OWNER_ACL_SUBJECTS_AS_SET] = subjects_as_set + + return subjects_as_set + + def get_org_admins(self): + # This retrieves the value of the org to be added if the user has one or + # more org-admin groups, otherwise the empty list. + return [ + group[: -len(self.ORG_ADMIN_SUFFIX)] + for group in self.payload.get("groups", []) + if group.endswith(self.ORG_ADMIN_SUFFIX) + ] + + def get_resource_admins(self): + # This retrieves the value of the access-id to be added if the user has one or + # more resource-admin groups, otherwise the empty list. + return [ + group[: -len(self.RESOURCE_ADMIN_SUFFIX)] + for group in self.payload.get("groups", []) + if group.endswith(self.RESOURCE_ADMIN_SUFFIX) + ] + + def _active_groups(self): + """ + Attempts to filter groups to just the ones that are currently valid for this + user. If they have a colon, the prefix leading up to the colon must be the + user's current org, otherwise the user should not actually have rights with + this group. + """ + org = self.payload.get("org") + for group in self.payload.get("groups", []): + parts = group.split(":") + + if len(parts) == 1: + yield group + elif org and parts[0] == org: + yield group + + def _clear_cache(self): + for key in ( + self.KEY_PAYLOAD, + self.KEY_ALL_ACL_SUBJECTS, + self.KEY_ALL_ACL_SUBJECTS_AS_SET, + self.KEY_ALL_OWNER_ACL_SUBJECTS, + self.KEY_ALL_OWNER_ACL_SUBJECTS_AS_SET, + ): + if key in self.__dict__: + del self.__dict__[key] + self._namespace = None + def __getstate__(self): return dict((attr, getattr(self, attr)) for attr in self.__attrs__) diff --git a/descarteslabs/auth/tests/test_auth.py b/descarteslabs/auth/tests/test_auth.py index 24048f2c..e763138e 100644 --- a/descarteslabs/auth/tests/test_auth.py +++ b/descarteslabs/auth/tests/test_auth.py @@ -490,6 +490,68 @@ def test_domain(self): a = Auth() assert a.domain == domain + def test_all_acl_subjects(self): + auth = Auth( + client_secret="client_secret", + client_id="ZOBAi4UROl5gKZIpxxlwOEfx8KpqXf2c", + ) + token = b".".join( + ( + base64.b64encode(to_bytes(p)) + for p in [ + "header", + json.dumps( + dict( + sub="some|user", + groups=["public"], + org="some-org", + exp=9999999999, + aud="ZOBAi4UROl5gKZIpxxlwOEfx8KpqXf2c", + ) + ), + "sig", + ] + ) + ) + auth._token = token + + assert { + Auth.ACL_PREFIX_USER + auth.namespace, + f"{Auth.ACL_PREFIX_GROUP}public", + f"{Auth.ACL_PREFIX_ORG}some-org", + } == set(auth.all_acl_subjects) + + def test_all_acl_subjects_ignores_bad_org_groups(self): + auth = Auth( + client_secret="client_secret", + client_id="ZOBAi4UROl5gKZIpxxlwOEfx8KpqXf2c", + ) + token = b".".join( + ( + base64.b64encode(to_bytes(p)) + for p in [ + "header", + json.dumps( + dict( + sub="some|user", + groups=["public", "some-org:baz", "other:baz"], + org="some-org", + exp=9999999999, + aud="ZOBAi4UROl5gKZIpxxlwOEfx8KpqXf2c", + ) + ), + "sig", + ] + ) + ) + auth._token = token + assert { + Auth.ACL_PREFIX_USER + auth.namespace, + f"{Auth.ACL_PREFIX_ORG}some-org", + f"{Auth.ACL_PREFIX_GROUP}public", + f"{Auth.ACL_PREFIX_GROUP}some-org:baz", + } == set(auth.all_acl_subjects) + if __name__ == "__main__": unittest.main() diff --git a/descarteslabs/core/catalog/__init__.py b/descarteslabs/core/catalog/__init__.py index 42184f8b..f5031d02 100644 --- a/descarteslabs/core/catalog/__init__.py +++ b/descarteslabs/core/catalog/__init__.py @@ -90,6 +90,7 @@ SummarySearchMixin, ) from .catalog_base import ( + AuthCatalogObject, CatalogClient, CatalogObject, DeletedObjectError, @@ -112,6 +113,7 @@ __all__ = [ "AggregateDateField", "AttributeValidationError", + "AuthCatalogObject", "Band", "BandCollection", "BandType", diff --git a/descarteslabs/core/catalog/blob.py b/descarteslabs/core/catalog/blob.py index a090e3a8..e38be1d6 100644 --- a/descarteslabs/core/catalog/blob.py +++ b/descarteslabs/core/catalog/blob.py @@ -25,7 +25,6 @@ DocumentState, EnumAttribute, GeometryAttribute, - ListAttribute, StorageState, Timestamp, TypedAttribute, @@ -33,9 +32,11 @@ ) from .blob_download import BlobDownload from .catalog_base import ( + AuthCatalogObject, CatalogClient, - CatalogObject, check_deleted, + check_derived, + hybridmethod, UnsavedObjectError, ) from .search import AggregateDateField, GeoSearch, SummarySearchMixin @@ -118,7 +119,7 @@ class BlobSearch(SummarySearchMixin, GeoSearch): DEFAULT_AGGREGATE_DATE_FIELD = AggregateDateField.CREATED -class Blob(CatalogObject): +class Blob(AuthCatalogObject): """A stored blob (arbitrary bytes) that can be searched and retrieved. Instantiating a blob indicates that you want to create a *new* Descartes Labs @@ -139,35 +140,6 @@ class Blob(CatalogObject): the exception of properties (`ATTRIBUTES`, `is_modified`, and `state`), any attribute listed below can also be used as a keyword argument. Also see `~Blob.ATTRIBUTES`. - - - .. _blob_note: - - Note - ---- - The ``reader`` and ``writer`` IDs must be prefixed with ``email:``, ``user:``, - ``group:`` or ``org:``. The ``owner`` ID only accepts ``org:`` and ``user:``. - Using ``org:`` as an ``owner`` will assign those privileges only to administrators - for that organization; using ``org:`` as a ``reader`` or ``writer`` assigns those - privileges to everyone in that organization. The `readers` and `writers` attributes - are only visible in full to the `owners`. If you are a `reader` or a `writer` those - attributes will only display the element of those lists by which you are gaining - read or write access. - - Any user with ``owner`` privileges is able to read the blob attributes or data, - modify the blob attributes, or delete the blob, including reading and modifying the - ``owners``, ``writers``, and ``readers`` attributes. - - Any user with ``writer`` privileges is able to read the blob attributes or data, - or modify the blob attributes, but not delete the blob. A ``writer`` can read the - ``owners`` and can only read the entry in the ``writers`` and/or ``readers`` - by which they gain access to the blob. - - Any user with ``reader`` privileges is able to read the blob attributes or data. - A ``reader`` can read the ``owners`` and can only read the entry - in the ``writers`` and/or ``readers`` by which they gain access to the blob. - - Also see :doc:`Sharing Resources `. """ _doc_type = "storage" @@ -269,33 +241,6 @@ class Blob(CatalogObject): hash = TypedAttribute( str, doc="""str, optional: Content hash (MD5) for the blob.""" ) - owners = ListAttribute( - TypedAttribute(str), - doc="""list(str), optional: User, group, or organization IDs that own this blob. - - Defaults to [``user:current_user``, ``org:current_org``]. The owner can edit, - delete, and change access to this blob. :ref:`See this note `. - - *Filterable*. - """, - ) - readers = ListAttribute( - TypedAttribute(str), - doc="""list(str), optional: User, email, group, or organization IDs that can read this blob. - - Will be empty by default. This attribute is only available in full to the `owners` - of the blob. :ref:`See this note `. - """, - ) - writers = ListAttribute( - TypedAttribute(str), - doc="""list(str), optional: User, group, or organization IDs that can edit this blob. - - Writers will also have read permission. Writers will be empty by default. - See note below. This attribute is only available in full to the `owners` of the blob. - :ref:`See this note `. - """, - ) @classmethod def namespace_id(cls, namespace_id, client=None): @@ -1020,8 +965,9 @@ def _do_download(self, dest=None, range=None): finally: r.close() - @classmethod - def _cls_delete(cls, id, client=None): + @hybridmethod + @check_derived + def delete(cls, id, client=None): """Delete the catalog object with the given `id`. Parameters @@ -1051,6 +997,10 @@ def _cls_delete(cls, id, client=None): Example ------- >>> Image.delete('my-image-id') # doctest: +SKIP + + There is also an instance ``delete`` method that can be used to delete a blob. + It accepts no parameters and also returns a ``BlobDeletionTaskStatus``. Once + deleted, you cannot use the blob and should release any references. """ if client is None: client = CatalogClient.get_default_client() @@ -1062,7 +1012,9 @@ def _cls_delete(cls, id, client=None): except NotFoundError: return None - def _instance_delete(self): + @delete.instancemethod + @check_deleted + def delete(self): """Delete this catalog object from the Descartes Labs catalog. Once deleted, you cannot use the catalog object and should release any diff --git a/descarteslabs/core/catalog/catalog_base.py b/descarteslabs/core/catalog/catalog_base.py index 9a5910b2..d4d2adf1 100644 --- a/descarteslabs/core/catalog/catalog_base.py +++ b/descarteslabs/core/catalog/catalog_base.py @@ -909,11 +909,11 @@ def delete(cls, id, client=None): Example ------- >>> Image.delete('my-image-id') # doctest: +SKIP - """ - return cls._cls_delete(id, client=client) - @classmethod - def _cls_delete(cls, id, client=None): + There is also an instance ``delete`` method that can be used to delete an object. + It accepts no parameters and does not return anything. Once deleted, you cannot + use the catalog object and should release any references. + """ if client is None: client = CatalogClient.get_default_client() @@ -941,9 +941,6 @@ def delete(self): :ref:`Spurious exception ` that can occur during a network request. """ - return self._instance_delete() - - def _instance_delete(self): if self.state == DocumentState.UNSAVED: raise UnsavedObjectError("You cannot delete an unsaved object.") @@ -1024,3 +1021,141 @@ class CatalogObject(CatalogObjectBase): def __new__(cls, *args, **kwargs): return _new_abstract_class(cls, CatalogObject) + + +class AuthCatalogObject(CatalogObject): + """A base class for all representations of objects in the Descartes Labs catalog + that support ACLs. + + .. _auth_note: + + Note + ---- + The `readers` and `writers` IDs must be prefixed with ``email:``, ``user:``, + ``group:`` or ``org:``. The `owners` IDs must be prefixed with ``org:`` or ``user:``. + Using ``org:`` as an owner will assign those privileges only to administrators + for that organization; using ``org:`` as a reader or writer assigns those + privileges to everyone in that organization. The `readers` and `writers` attributes + are only visible in full to an owner. If you are a reader or a writer those + attributes will only display the elements of those lists by which you are gaining + read or write access. + + Any user with owner privileges is able to read the object attributes and data, + modify the object attributes, and delete the object, including reading and modifying the + `owners`, `writers`, and `readers` attributes. + + Any user with writer privileges is able to read the object attributes and data, + modify the object attributes except for `owners`, `writers`, and `readers`. + A writer cannot delete the object. A writer can read the `owners` attribute but + can only read the elements of `writers` and `readers` by which they gain access + to the object. + + Any user with reader privileges is able to read the objects attributes and data. + A reader can read the `owners` attribute but can only read the elements of + `writers` and `readers` by which they gain access to the object. + + Also see :doc:`Sharing Resources `. + """ + + owners = ListAttribute( + TypedAttribute(str), + doc="""list(str), optional: User, group, or organization IDs that own this object. + + Defaults to [``user:current_user``, ``org:current_org``]. The owner can edit, + delete, and change access to this object. :ref:`See this note `. + + *Filterable*. + """, + ) + readers = ListAttribute( + TypedAttribute(str), + doc="""list(str), optional: User, email, group, or organization IDs that can read this object. + + Will be empty by default. This attribute is only available in full to the `owners` + of the object. :ref:`See this note `. + """, + ) + writers = ListAttribute( + TypedAttribute(str), + doc="""list(str), optional: User, group, or organization IDs that can edit this object. + + Writers will also have read permission. Writers will be empty by default. + See note below. This attribute is only available in full to the `owners` of the object. + :ref:`See this note `. + """, + ) + + def __new__(cls, *args, **kwargs): + return _new_abstract_class(cls, AuthCatalogObject) + + def user_is_owner(self, auth=None): + """Check if the authenticated user is an owner, and can + perform actions such as changing ACLs or deleting this object. + + Parameters + ---------- + auth : Auth, optional + The auth object to use for the check. If not provided, the default auth object + will be used. + + Returns + ------- + bool + True if the user is an owner of the object, False otherwise. + """ + if auth is None: + auth = self._client.auth + + return "descarteslabs:platform-admin" in auth.payload.get("groups", []) or bool( + set(self.owners) & auth.all_owner_acl_subjects_as_set + ) + + def user_can_write(self, auth=None): + """Check if the authenticated user is an owner or a writer and has permissions + to modify this object. + + Parameters + ---------- + auth : Auth, optional + The auth object to use for the check. If not provided, the default auth object + will be used. + + Returns + ------- + bool + True if the user can modify the object, False otherwise. + """ + if auth is None: + auth = self._client.auth + + return self.user_is_owner(auth) or bool( + set(self.writers) & auth.all_acl_subjects_as_set + ) + + def user_can_read(self, auth=None): + """Check if the authenticated user is an owner, a writer, or a reader + and has permissions to read this object. + + Note it is kind of silly to call this method unless a non-default auth + object is provided, because the default authorized user must have read + permission in order to even retrieve this object. + + Parameters + ---------- + auth : Auth, optional + The auth object to use for the check. If not provided, the default auth object + will be used. + + Returns + ------- + bool + True if the user can read the object, False otherwise. + """ + if auth is None: + auth = self._client.auth + + return ( + "descarteslabs:platform-ro" in auth.payload.get("groups", []) + or self.user_can_write(auth) + or bool(set(self.readers) & auth.all_acl_subjects_as_set) + ) diff --git a/descarteslabs/core/catalog/event_api_destination.py b/descarteslabs/core/catalog/event_api_destination.py index dc7ffb27..6dce49e1 100644 --- a/descarteslabs/core/catalog/event_api_destination.py +++ b/descarteslabs/core/catalog/event_api_destination.py @@ -21,8 +21,8 @@ TypedAttribute, ) from .catalog_base import ( + AuthCatalogObject, CatalogClient, - CatalogObject, ) from .search import Search @@ -63,7 +63,7 @@ class EventApiDestinationSearch(Search): pass -class EventApiDestination(CatalogObject): +class EventApiDestination(AuthCatalogObject): """An EventBridge API destination. @@ -78,37 +78,6 @@ class EventApiDestination(CatalogObject): and with the exception of properties (`ATTRIBUTES`, `is_modified`, and `state`), any attribute listed below can also be used as a keyword argument. Also see `~EventApiDestination.ATTRIBUTES`. - - - .. _event_api_destination_note: - - Note - ---- - The ``reader`` and ``writer`` IDs must be prefixed with ``email:``, ``user:``, - ``group:`` or ``org:``. The ``owner`` ID only accepts ``org:`` and ``user:``. - Using ``org:`` as an ``owner`` will assign those privileges only to administrators - for that organization; using ``org:`` as a ``reader`` or ``writer`` assigns those - privileges to everyone in that organization. The `readers` and `writers` attributes - are only visible in full to the `owners`. If you are a `reader` or a `writer` those - attributes will only display the element of those lists by which you are gaining - read or write access. - - Any user with ``owner`` privileges is able to read the event api destination - attributes or data, modify the event api destination attributes, or delete the event - api destination, including reading and modifying the ``owners``, ``writers``, and - ``readers`` attributes. - - Any user with ``writer`` privileges is able to read the event api destination attributes - or data, or modify the event api destination attributes, but not delete the event api - destination. A ``writer`` can read the ``owners`` and can only read the entry in the - ``writers`` and/or ``readers`` by which they gain access to the event api destination. - - Any user with ``reader`` privileges is able to read the event api destination - attributes or data. A ``reader`` can read the ``owners`` and can only read the entry - in the ``writers`` and/or ``readers`` by which they gain access to the event api - destination. - - Also see :doc:`Sharing Resources `. """ _doc_type = "event_api_destination" @@ -271,33 +240,6 @@ class EventApiDestination(CatalogObject): str, doc="""str: The ARN of the connection.""", ) - owners = ListAttribute( - TypedAttribute(str), - doc="""list(str), optional: User, group, or organization IDs that own this event api destination. - - Defaults to [``user:current_user``, ``org:current_org``]. The owner can edit, - delete, and change access to this event api destination. :ref:`See this note `. - - *Filterable*. - """, - ) - readers = ListAttribute( - TypedAttribute(str), - doc="""list(str), optional: User, email, group, or organization IDs that can read this event api destination. - - Will be empty by default. This attribute is only available in full to the `owners` - of the event api destination. :ref:`See this note `. - """, - ) - writers = ListAttribute( - TypedAttribute(str), - doc="""list(str), optional: User, group, or organization IDs that can edit this event api destination. - - Writers will also have read permission. Writers will be empty by default. - See note below. This attribute is only available in full to the `owners` of the event api destination. - :ref:`See this note `. - """, - ) @classmethod def namespace_id(cls, namespace_id, client=None): diff --git a/descarteslabs/core/catalog/event_rule.py b/descarteslabs/core/catalog/event_rule.py index 8de6e0cd..7244f25d 100644 --- a/descarteslabs/core/catalog/event_rule.py +++ b/descarteslabs/core/catalog/event_rule.py @@ -22,8 +22,8 @@ TypedAttribute, ) from .catalog_base import ( + AuthCatalogObject, CatalogClient, - CatalogObject, ) from .search import Search @@ -134,7 +134,7 @@ class EventRuleSearch(Search): pass -class EventRule(CatalogObject): +class EventRule(AuthCatalogObject): """An EventBridge rule to match event subscription targets. @@ -149,35 +149,6 @@ class EventRule(CatalogObject): and with the exception of properties (`ATTRIBUTES`, `is_modified`, and `state`), any attribute listed below can also be used as a keyword argument. Also see `~EventRule.ATTRIBUTES`. - - - .. _event_rule_note: - - Note - ---- - The ``reader`` and ``writer`` IDs must be prefixed with ``email:``, ``user:``, - ``group:`` or ``org:``. The ``owner`` ID only accepts ``org:`` and ``user:``. - Using ``org:`` as an ``owner`` will assign those privileges only to administrators - for that organization; using ``org:`` as a ``reader`` or ``writer`` assigns those - privileges to everyone in that organization. The `readers` and `writers` attributes - are only visible in full to the `owners`. If you are a `reader` or a `writer` those - attributes will only display the element of those lists by which you are gaining - read or write access. - - Any user with ``owner`` privileges is able to read the event rule attributes or data, - modify the event rule attributes, or delete the event rule, including reading - and modifying the ``owners``, ``writers``, and ``readers`` attributes. - - Any user with ``writer`` privileges is able to read the event rule attributes or data, - or modify the event rule attributes, but not delete the event rule. A ``writer`` - can read the ``owners`` and can only read the entry in the ``writers`` and/or ``readers`` - by which they gain access to the event rule. - - Any user with ``reader`` privileges is able to read the event rule attributes or data. - A ``reader`` can read the ``owners`` and can only read the entry in the ``writers`` and/or - ``readers`` by which they gain access to the event rule. - - Also see :doc:`Sharing Resources `. """ _doc_type = "event_rule" @@ -267,33 +238,6 @@ class EventRule(CatalogObject): str, doc="""str: The ARN of the rule.""", ) - owners = ListAttribute( - TypedAttribute(str), - doc="""list(str), optional: User, group, or organization IDs that own this event rule. - - Defaults to [``user:current_user``, ``org:current_org``]. The owner can edit, - delete, and change access to this event rule. :ref:`See this note `. - - *Filterable*. - """, - ) - readers = ListAttribute( - TypedAttribute(str), - doc="""list(str), optional: User, email, group, or organization IDs that can read this event rule. - - Will be empty by default. This attribute is only available in full to the `owners` - of the event rule. :ref:`See this note `. - """, - ) - writers = ListAttribute( - TypedAttribute(str), - doc="""list(str), optional: User, group, or organization IDs that can edit this event rule. - - Writers will also have read permission. Writers will be empty by default. - See note below. This attribute is only available in full to the `owners` of the event rule. - :ref:`See this note `. - """, - ) @classmethod def namespace_id(cls, namespace_id, client=None): diff --git a/descarteslabs/core/catalog/event_schedule.py b/descarteslabs/core/catalog/event_schedule.py index 53323bd7..572235f2 100644 --- a/descarteslabs/core/catalog/event_schedule.py +++ b/descarteslabs/core/catalog/event_schedule.py @@ -16,13 +16,12 @@ from ..common.collection import Collection from .attributes import ( BooleanAttribute, - ListAttribute, Timestamp, TypedAttribute, ) from .catalog_base import ( + AuthCatalogObject, CatalogClient, - CatalogObject, ) from .search import Search @@ -36,7 +35,7 @@ class EventScheduleSearch(Search): pass -class EventSchedule(CatalogObject): +class EventSchedule(AuthCatalogObject): """A Scheduled Event. @@ -51,35 +50,6 @@ class EventSchedule(CatalogObject): and with the exception of properties (`ATTRIBUTES`, `is_modified`, and `state`), any attribute listed below can also be used as a keyword argument. Also see `~EventSchedule.ATTRIBUTES`. - - - .. _event_schedule_note: - - Note - ---- - The ``reader`` and ``writer`` IDs must be prefixed with ``email:``, ``user:``, - ``group:`` or ``org:``. The ``owner`` ID only accepts ``org:`` and ``user:``. - Using ``org:`` as an ``owner`` will assign those privileges only to administrators - for that organization; using ``org:`` as a ``reader`` or ``writer`` assigns those - privileges to everyone in that organization. The `readers` and `writers` attributes - are only visible in full to the `owners`. If you are a `reader` or a `writer` those - attributes will only display the element of those lists by which you are gaining - read or write access. - - Any user with ``owner`` privileges is able to read the event schedule attributes or data, - modify the event schedule attributes, or delete the event schedule, including reading - and modifying the ``owners``, ``writers``, and ``readers`` attributes. - - Any user with ``writer`` privileges is able to read the event schedule attributes or data, - or modify the event schedule attributes, but not delete the event schedule. A ``writer`` - can read the ``owners`` and can only read the entry in the ``writers`` and/or ``readers`` - by which they gain access to the event schedule. - - Any user with ``reader`` privileges is able to read the event schedule attributes or data. - A ``reader`` can read the ``owners`` and can only read the entry in the ``writers`` and/or - ``readers`` by which they gain access to the event schedule. - - Also see :doc:`Sharing Resources `. """ _doc_type = "event_schedule" @@ -188,33 +158,6 @@ class EventSchedule(CatalogObject): *Filterable, sortable*. """, ) - owners = ListAttribute( - TypedAttribute(str), - doc="""list(str), optional: User, group, or organization IDs that own this event schedule. - - Defaults to [``user:current_user``, ``org:current_org``]. The owner can edit, - delete, and change access to this event schedule. :ref:`See this note `. - - *Filterable*. - """, - ) - readers = ListAttribute( - TypedAttribute(str), - doc="""list(str), optional: User, email, group, or organization IDs that can read this event schedule. - - Will be empty by default. This attribute is only available in full to the `owners` - of the event schedule. :ref:`See this note `. - """, - ) - writers = ListAttribute( - TypedAttribute(str), - doc="""list(str), optional: User, group, or organization IDs that can edit this event schedule. - - Writers will also have read permission. Writers will be empty by default. - See note below. This attribute is only available in full to the `owners` of the event schedule. - :ref:`See this note `. - """, - ) @classmethod def namespace_id(cls, namespace_id, client=None): diff --git a/descarteslabs/core/catalog/event_subscription.py b/descarteslabs/core/catalog/event_subscription.py index 05d91b77..d97e419c 100644 --- a/descarteslabs/core/catalog/event_subscription.py +++ b/descarteslabs/core/catalog/event_subscription.py @@ -32,8 +32,8 @@ TypedAttribute, ) from .catalog_base import ( + AuthCatalogObject, CatalogClient, - CatalogObject, ) from .search import GeoSearch @@ -344,7 +344,7 @@ class EventSubscriptionSearch(GeoSearch): pass -class EventSubscription(CatalogObject): +class EventSubscription(AuthCatalogObject): """A Subscription to receive event notifications. @@ -359,35 +359,6 @@ class EventSubscription(CatalogObject): `owner_role_arn`), and with the exception of properties (`ATTRIBUTES`, `is_modified`, and `state`), any attribute listed below can also be used as a keyword argument. Also see `~EventSubscription.ATTRIBUTES`. - - - .. _event_subscription_note: - - Note - ---- - The ``readers`` and ``writers`` IDs must be prefixed with ``email:``, ``user:``, - ``group:`` or ``org:``. The ``owners`` ID only accepts ``org:`` and ``user:``. - Using ``org:`` as an owner will assign those privileges only to administrators - for that organization; using ``org:`` as a reader or writer assigns those - privileges to everyone in that organization. The `readers` and `writers` attributes - are only visible in full to the `owners`. If you are a reader or a writer those - attributes will only display the element of those lists by which you are gaining - read or write access. - - Any user with owner privileges is able to read the event subscription attributes or data, - modify the event subscription attributes, or delete the event subscription, including reading - and modifying the ``owners``, ``writers``, and ``readers`` attributes. - - Any user with writer privileges is able to read the event subscription attributes or data, - or modify the event subscription attributes, but not delete the event subscription. A ``writer`` - can read the ``owners`` and can only read the entry in the ``writers`` and/or ``readers`` - by which they gain access to the event subscription. - - Any user with reader privileges is able to read the event subscription attributes or data. - A reader can read the ``owners`` and can only read the entry in the ``writers`` and/or - ``readers`` by which they gain access to the event subscription. - - Also see :doc:`Sharing Resources `. """ _doc_type = "event_subscription" @@ -508,33 +479,6 @@ class EventSubscription(CatalogObject): *Filterable, sortable*. """, ) - owners = ListAttribute( - TypedAttribute(str), - doc="""list(str), optional: User, group, or organization IDs that own this event subscription. - - Defaults to [``user:current_user``, ``org:current_org``]. The owner can edit, - delete, and change access to this event subscription. :ref:`See this note `. - - *Filterable*. - """, - ) - readers = ListAttribute( - TypedAttribute(str), - doc="""list(str), optional: User, email, group, or organization IDs that can read this event subscription. - - Will be empty by default. This attribute is only available in full to the `owners` - of the event subscription. :ref:`See this note `. - """, - ) - writers = ListAttribute( - TypedAttribute(str), - doc="""list(str), optional: User, group, or organization IDs that can edit this event subscription. - - Writers will also have read permission. Writers will be empty by default. - See note below. This attribute is only available in full to the `owners` of the event subscription. - :ref:`See this note `. - """, - ) @classmethod def namespace_id(cls, namespace_id, client=None): diff --git a/descarteslabs/core/catalog/image_upload.py b/descarteslabs/core/catalog/image_upload.py index 918d8d9e..c29fa2b9 100644 --- a/descarteslabs/core/catalog/image_upload.py +++ b/descarteslabs/core/catalog/image_upload.py @@ -23,7 +23,7 @@ from descarteslabs.exceptions import ServerError -from .catalog_base import CatalogObjectBase, check_deleted +from .catalog_base import CatalogObjectBase, check_deleted, check_derived, hybridmethod from .attributes import ( Attribute, CatalogObjectReference, @@ -541,8 +541,9 @@ def _load_related_objects(cls, response, client): return related_objects - @classmethod - def _cls_delete(cls, id, client=None): + @hybridmethod + @check_derived + def delete(cls, id, client=None): """You cannot delete an ImageUpload. Raises @@ -552,5 +553,14 @@ def _cls_delete(cls, id, client=None): """ raise NotImplementedError("Deleting ImageUploads is not permitted") - def _instance_delete(self): + @delete.instancemethod + @check_deleted + def delete(self): + """You cannot delete an ImageUpload. + + Raises + ------ + NotImplementedError + This method is not supported for ImageUploads. + """ raise NotImplementedError("Deleting ImageUploads is not permitted") diff --git a/descarteslabs/core/catalog/product.py b/descarteslabs/core/catalog/product.py index 67b5e6a3..93ebc6cd 100644 --- a/descarteslabs/core/catalog/product.py +++ b/descarteslabs/core/catalog/product.py @@ -22,8 +22,8 @@ TypedAttribute, ) from .catalog_base import ( + AuthCatalogObject, CatalogClient, - CatalogObject, check_deleted, ) from .task import TaskStatus @@ -32,7 +32,7 @@ properties = Properties() -class Product(CatalogObject): +class Product(AuthCatalogObject): """A raster product that connects band information to imagery. Instantiating a product indicates that you want to create a *new* Descartes Labs @@ -55,36 +55,6 @@ class Product(CatalogObject): (`ATTRIBUTES`, `is_modified`, and `state`), any attribute listed below can also be used as a keyword argument. Also see `~Product.ATTRIBUTES`. - - - .. _product_note: - - Note - ---- - The ``reader`` and ``writer`` IDs must be prefixed with ``email:``, ``user:``, - ``group:`` or ``org:``. The ``owner`` ID only accepts ``org:`` and ``user:``. - Using ``org:`` as an ``owner`` will assign those privileges only to administrators - for that organization; using ``org:`` as a ``reader`` or ``writer`` assigns those - privileges to everyone in that organization. The `readers` and `writers` attributes - are only visible in full to the `owners`. If you are a `reader` or a `writer` those - attributes will only display the element of those lists by which you are gaining - read or write access. - - Any user with ``owner`` privileges is able to read, modify, or delete the product, - including reading and modifying the ``owners``, ``writers``, and ``readers`` attributes. - Any user with ``owner`` privileges can also create, read, modify, or delete bands - and images for the product. - - Any user with ``writer`` privileges is able to read or modify the product, but not - delete the product. A ``writer`` may create, read or modify bands and images for the - product. A ``writer`` can read the product ``owners`` and can only read the entry - in the ``writers`` and/or ``readers`` by which they gain access to the product. - - Any user with ``reader`` privileges is able to read the product, bands, and images. - A ``reader`` can read the product ``owners`` and can only read the entry - in the ``writers`` and/or ``readers`` by which they gain access to the product. - - Also see :doc:`Sharing Resources `. """ _doc_type = "product" @@ -114,33 +84,6 @@ class Product(CatalogObject): *Searchable* """, ) - owners = ListAttribute( - TypedAttribute(str), - doc="""list(str), optional: User, group, or organization IDs that own this product. - - Defaults to [``user:current_user``, ``org:current_org``]. The owner can edit, - delete, and change access to this product. :ref:`See this note `. - - *Filterable*. - """, - ) - readers = ListAttribute( - TypedAttribute(str), - doc="""list(str), optional: User, email, group, or organization IDs that can read this product. - - Will be empty by default. This attribute is only available in full to the `owners` - of the product. :ref:`See this note `. - """, - ) - writers = ListAttribute( - TypedAttribute(str), - doc="""list(str), optional: User, group, or organization IDs that can edit this product. - - Writers will also have read permission. Writers will be empty by default. - See note below. This attribute is only available in full to the `owners` of the product. - :ref:`See this note `. - """, - ) is_core = BooleanAttribute( doc="""bool, optional: Whether this is a Descartes Labs catalog core product. diff --git a/descarteslabs/core/catalog/tests/base.py b/descarteslabs/core/catalog/tests/base.py index a932064a..0355d8df 100644 --- a/descarteslabs/core/catalog/tests/base.py +++ b/descarteslabs/core/catalog/tests/base.py @@ -43,6 +43,8 @@ def setUp(self): { "aud": "ZOBAi4UROl5gKZIpxxlwOEfx8KpqXf2c", "exp": time.time() + 3600, + "sub": "some|user", + "org": "some-org", } ).encode() ) diff --git a/descarteslabs/core/catalog/tests/test_catalog_base.py b/descarteslabs/core/catalog/tests/test_catalog_base.py index 9feae4b5..71fdce32 100644 --- a/descarteslabs/core/catalog/tests/test_catalog_base.py +++ b/descarteslabs/core/catalog/tests/test_catalog_base.py @@ -23,7 +23,6 @@ ConflictError, ) -from .base import ClientTestCase from ..attributes import ( Attribute, AttributeValidationError, @@ -31,12 +30,18 @@ DocumentState, ) from ..catalog_base import ( + AuthCatalogObject as OriginalAuthCatalogObject, CatalogClient, CatalogObject as OriginalCatalogObject, DeletedObjectError, UnsavedObjectError, ) from ..named_catalog_base import NamedCatalogObject +from .base import ClientTestCase + + +class AuthCatalogObject(OriginalAuthCatalogObject): + pass class CatalogObject(OriginalCatalogObject): @@ -678,3 +683,64 @@ def test_deleted_notfound(self): with pytest.raises(DeletedObjectError): instance.reload() assert instance.state == DocumentState.DELETED + + +class TestAuthCatalogObject(ClientTestCase): + def test_auth_catalog_object(self): + user = self.client.auth.namespace + org = self.client.auth.payload["org"] + obj = AuthCatalogObject( + id="id", + owners=[f"org:{org}", f"user:{user}"], + writers=["org:some-org"], + readers=["group:some-group"], + client=self.client, + ) + + assert obj.user_is_owner() + assert obj.user_can_write() + assert obj.user_can_read() + + auth = self.client.auth + payload = auth.payload + auth._clear_cache() + assert auth._namespace is None + assert "_payload" not in auth.__dict__ + payload.update( + { + "sub": "some|other-user", + } + ) + auth.__dict__["_payload"] = payload + + assert not obj.user_is_owner(auth) + assert obj.user_can_write(auth) + assert obj.user_can_read(auth) + + auth._clear_cache() + payload.update( + { + "sub": "some|other-user", + "org": "some-other-org", + "groups": ["some-group"], + } + ) + auth.__dict__["_payload"] = payload + + assert not obj.user_is_owner(auth) + assert not obj.user_can_write(auth) + assert obj.user_can_read(auth) + + auth._clear_cache() + payload.update( + { + "sub": "some|other-user", + "org": "some-other-org", + "groups": ["some-other-group"], + } + ) + auth.__dict__["_payload"] = payload + + assert not obj.user_is_owner(auth) + assert not obj.user_can_write(auth) + assert not obj.user_can_read(auth)