Skip to content

Commit 483f675

Browse files
stephencpopeDescartes Labs Build
authored andcommitted
[Core-551] Client: Add methods to test read/write/ownership of catalog objects (#12711)
GitOrigin-RevId: bbadc84268bf6fdd67455fda2fad30aa8120e7ea
1 parent cbf291b commit 483f675

File tree

13 files changed

+460
-369
lines changed

13 files changed

+460
-369
lines changed

descarteslabs/auth/auth.py

Lines changed: 147 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -153,6 +153,28 @@ class Auth:
153153
KEY_JWT_TOKEN = "jwt_token"
154154
KEY_ALT_JWT_TOKEN = "JWT_TOKEN"
155155

156+
# The various prefixes that can be used in Catalog ACLs.
157+
ACL_PREFIX_USER = "user:" # Followed by the user's sha1 hash
158+
ACL_PREFIX_EMAIL = "email:" # Followed by the user's email
159+
ACL_PREFIX_GROUP = "group:" # Followed by a lowercase group
160+
ACL_PREFIX_ORG = "org:" # Followed by a lowercase org name
161+
ACL_PREFIX_ACCESS = "access-id:" # Followed by the purchase-specific access id
162+
# Note that the access-id, including the prefix `access_id:`, is matched against
163+
# a group with the same name. In other words `group:access-id:<access-id>` will
164+
# match against `access-id:<access-id>` (assuming the `<access_id>` is identical).
165+
166+
# these match the values in descarteslabs/common/services/python_auth/groups.py
167+
ORG_ADMIN_SUFFIX = ":org-admin"
168+
RESOURCE_ADMIN_SUFFIX = ":resource-admin"
169+
170+
# These are cache keys for caching various data in the object's __dict__.
171+
# These are scrubbed out with `_clear_cache()` when retrieving a new token.
172+
KEY_PAYLOAD = "_payload"
173+
KEY_ALL_ACL_SUBJECTS = "_aas"
174+
KEY_ALL_ACL_SUBJECTS_AS_SET = "_aasas"
175+
KEY_ALL_OWNER_ACL_SUBJECTS = "_aoas"
176+
KEY_ALL_OWNER_ACL_SUBJECTS_AS_SET = "_aoasas"
177+
156178
__attrs__ = [
157179
"domain",
158180
"scope",
@@ -585,7 +607,13 @@ def payload(self):
585607
OauthError
586608
Raised when a token cannot be obtained or refreshed.
587609
"""
588-
return self._get_payload(self.token)
610+
payload = self.__dict__.get(self.KEY_PAYLOAD)
611+
612+
if payload is None:
613+
payload = self._get_payload(self.token)
614+
self.__dict__[self.KEY_PAYLOAD] = payload
615+
616+
return payload
589617

590618
@staticmethod
591619
def _get_payload(token):
@@ -754,6 +782,9 @@ def _get_token(self, timeout=100):
754782
else:
755783
raise OauthError("Could not retrieve token")
756784

785+
# clear out payload and subjects cache
786+
self._clear_cache()
787+
757788
token_info = {}
758789

759790
# Read the token from the token_info_path, and save it again
@@ -797,6 +828,121 @@ def namespace(self):
797828
self._namespace = sha1(self.payload["sub"].encode("utf-8")).hexdigest()
798829
return self._namespace
799830

831+
@property
832+
def all_acl_subjects(self):
833+
"""
834+
A list of all ACL subjects identifying this user (the user itself, the org, the
835+
groups) which can be used in ACL queries.
836+
"""
837+
subjects = self.__dict__.get(self.KEY_ALL_ACL_SUBJECTS)
838+
839+
if subjects is None:
840+
subjects = [self.ACL_PREFIX_USER + self.namespace]
841+
842+
if email := self.payload.get("email"):
843+
subjects.append(self.ACL_PREFIX_EMAIL + email.lower())
844+
845+
if org := self.payload.get("org"):
846+
subjects.append(self.ACL_PREFIX_ORG + org)
847+
848+
subjects += [
849+
self.ACL_PREFIX_GROUP + group for group in self._active_groups()
850+
]
851+
self.__dict__[self.KEY_ALL_ACL_SUBJECTS] = subjects
852+
853+
return subjects
854+
855+
@property
856+
def all_acl_subjects_as_set(self):
857+
subjects_as_set = self.__dict__.get(self.KEY_ALL_ACL_SUBJECTS_AS_SET)
858+
859+
if subjects_as_set is None:
860+
subjects_as_set = set(self.all_acl_subjects)
861+
self.__dict__[self.KEY_ALL_ACL_SUBJECTS_AS_SET] = subjects_as_set
862+
863+
return subjects_as_set
864+
865+
@property
866+
def all_owner_acl_subjects(self):
867+
"""
868+
A list of ACL subjects identifying this user (the user itself, the org,
869+
org admin and catalog admins) which can be used in owner ACL queries.
870+
"""
871+
subjects = self.__dict__.get(self.KEY_ALL_OWNER_ACL_SUBJECTS)
872+
873+
if subjects is None:
874+
subjects = [self.ACL_PREFIX_USER + self.namespace]
875+
876+
subjects.extend(
877+
[self.ACL_PREFIX_ORG + org for org in self.get_org_admins() if org]
878+
)
879+
subjects.extend(
880+
[
881+
self.ACL_PREFIX_ACCESS + access_id
882+
for access_id in self.get_resource_admins()
883+
if access_id
884+
]
885+
)
886+
self.__dict__[self.KEY_ALL_OWNER_ACL_SUBJECTS] = subjects
887+
888+
return subjects
889+
890+
@property
891+
def all_owner_acl_subjects_as_set(self):
892+
subjects_as_set = self.__dict__.get(self.KEY_ALL_OWNER_ACL_SUBJECTS_AS_SET)
893+
894+
if subjects_as_set is None:
895+
subjects_as_set = set(self.all_owner_acl_subjects)
896+
self.__dict__[self.KEY_ALL_OWNER_ACL_SUBJECTS_AS_SET] = subjects_as_set
897+
898+
return subjects_as_set
899+
900+
def get_org_admins(self):
901+
# This retrieves the value of the org to be added if the user has one or
902+
# more org-admin groups, otherwise the empty list.
903+
return [
904+
group[: -len(self.ORG_ADMIN_SUFFIX)]
905+
for group in self.payload.get("groups", [])
906+
if group.endswith(self.ORG_ADMIN_SUFFIX)
907+
]
908+
909+
def get_resource_admins(self):
910+
# This retrieves the value of the access-id to be added if the user has one or
911+
# more resource-admin groups, otherwise the empty list.
912+
return [
913+
group[: -len(self.RESOURCE_ADMIN_SUFFIX)]
914+
for group in self.payload.get("groups", [])
915+
if group.endswith(self.RESOURCE_ADMIN_SUFFIX)
916+
]
917+
918+
def _active_groups(self):
919+
"""
920+
Attempts to filter groups to just the ones that are currently valid for this
921+
user. If they have a colon, the prefix leading up to the colon must be the
922+
user's current org, otherwise the user should not actually have rights with
923+
this group.
924+
"""
925+
org = self.payload.get("org")
926+
for group in self.payload.get("groups", []):
927+
parts = group.split(":")
928+
929+
if len(parts) == 1:
930+
yield group
931+
elif org and parts[0] == org:
932+
yield group
933+
934+
def _clear_cache(self):
935+
for key in (
936+
self.KEY_PAYLOAD,
937+
self.KEY_ALL_ACL_SUBJECTS,
938+
self.KEY_ALL_ACL_SUBJECTS_AS_SET,
939+
self.KEY_ALL_OWNER_ACL_SUBJECTS,
940+
self.KEY_ALL_OWNER_ACL_SUBJECTS_AS_SET,
941+
):
942+
if key in self.__dict__:
943+
del self.__dict__[key]
944+
self._namespace = None
945+
800946
def __getstate__(self):
801947
return dict((attr, getattr(self, attr)) for attr in self.__attrs__)
802948

descarteslabs/auth/tests/test_auth.py

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -490,6 +490,68 @@ def test_domain(self):
490490
a = Auth()
491491
assert a.domain == domain
492492

493+
def test_all_acl_subjects(self):
494+
auth = Auth(
495+
client_secret="client_secret",
496+
client_id="ZOBAi4UROl5gKZIpxxlwOEfx8KpqXf2c",
497+
)
498+
token = b".".join(
499+
(
500+
base64.b64encode(to_bytes(p))
501+
for p in [
502+
"header",
503+
json.dumps(
504+
dict(
505+
sub="some|user",
506+
groups=["public"],
507+
org="some-org",
508+
exp=9999999999,
509+
aud="ZOBAi4UROl5gKZIpxxlwOEfx8KpqXf2c",
510+
)
511+
),
512+
"sig",
513+
]
514+
)
515+
)
516+
auth._token = token
517+
518+
assert {
519+
Auth.ACL_PREFIX_USER + auth.namespace,
520+
f"{Auth.ACL_PREFIX_GROUP}public",
521+
f"{Auth.ACL_PREFIX_ORG}some-org",
522+
} == set(auth.all_acl_subjects)
523+
524+
def test_all_acl_subjects_ignores_bad_org_groups(self):
525+
auth = Auth(
526+
client_secret="client_secret",
527+
client_id="ZOBAi4UROl5gKZIpxxlwOEfx8KpqXf2c",
528+
)
529+
token = b".".join(
530+
(
531+
base64.b64encode(to_bytes(p))
532+
for p in [
533+
"header",
534+
json.dumps(
535+
dict(
536+
sub="some|user",
537+
groups=["public", "some-org:baz", "other:baz"],
538+
org="some-org",
539+
exp=9999999999,
540+
aud="ZOBAi4UROl5gKZIpxxlwOEfx8KpqXf2c",
541+
)
542+
),
543+
"sig",
544+
]
545+
)
546+
)
547+
auth._token = token
548+
assert {
549+
Auth.ACL_PREFIX_USER + auth.namespace,
550+
f"{Auth.ACL_PREFIX_ORG}some-org",
551+
f"{Auth.ACL_PREFIX_GROUP}public",
552+
f"{Auth.ACL_PREFIX_GROUP}some-org:baz",
553+
} == set(auth.all_acl_subjects)
554+
493555

494556
if __name__ == "__main__":
495557
unittest.main()

descarteslabs/core/catalog/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,7 @@
9090
SummarySearchMixin,
9191
)
9292
from .catalog_base import (
93+
AuthCatalogObject,
9394
CatalogClient,
9495
CatalogObject,
9596
DeletedObjectError,
@@ -112,6 +113,7 @@
112113
__all__ = [
113114
"AggregateDateField",
114115
"AttributeValidationError",
116+
"AuthCatalogObject",
115117
"Band",
116118
"BandCollection",
117119
"BandType",

descarteslabs/core/catalog/blob.py

Lines changed: 14 additions & 62 deletions
Original file line numberDiff line numberDiff line change
@@ -25,17 +25,18 @@
2525
DocumentState,
2626
EnumAttribute,
2727
GeometryAttribute,
28-
ListAttribute,
2928
StorageState,
3029
Timestamp,
3130
TypedAttribute,
3231
parse_iso_datetime,
3332
)
3433
from .blob_download import BlobDownload
3534
from .catalog_base import (
35+
AuthCatalogObject,
3636
CatalogClient,
37-
CatalogObject,
3837
check_deleted,
38+
check_derived,
39+
hybridmethod,
3940
UnsavedObjectError,
4041
)
4142
from .search import AggregateDateField, GeoSearch, SummarySearchMixin
@@ -118,7 +119,7 @@ class BlobSearch(SummarySearchMixin, GeoSearch):
118119
DEFAULT_AGGREGATE_DATE_FIELD = AggregateDateField.CREATED
119120

120121

121-
class Blob(CatalogObject):
122+
class Blob(AuthCatalogObject):
122123
"""A stored blob (arbitrary bytes) that can be searched and retrieved.
123124
124125
Instantiating a blob indicates that you want to create a *new* Descartes Labs
@@ -139,35 +140,6 @@ class Blob(CatalogObject):
139140
the exception of properties (`ATTRIBUTES`, `is_modified`, and `state`), any
140141
attribute listed below can also be used as a keyword argument. Also see
141142
`~Blob.ATTRIBUTES`.
142-
143-
144-
.. _blob_note:
145-
146-
Note
147-
----
148-
The ``reader`` and ``writer`` IDs must be prefixed with ``email:``, ``user:``,
149-
``group:`` or ``org:``. The ``owner`` ID only accepts ``org:`` and ``user:``.
150-
Using ``org:`` as an ``owner`` will assign those privileges only to administrators
151-
for that organization; using ``org:`` as a ``reader`` or ``writer`` assigns those
152-
privileges to everyone in that organization. The `readers` and `writers` attributes
153-
are only visible in full to the `owners`. If you are a `reader` or a `writer` those
154-
attributes will only display the element of those lists by which you are gaining
155-
read or write access.
156-
157-
Any user with ``owner`` privileges is able to read the blob attributes or data,
158-
modify the blob attributes, or delete the blob, including reading and modifying the
159-
``owners``, ``writers``, and ``readers`` attributes.
160-
161-
Any user with ``writer`` privileges is able to read the blob attributes or data,
162-
or modify the blob attributes, but not delete the blob. A ``writer`` can read the
163-
``owners`` and can only read the entry in the ``writers`` and/or ``readers``
164-
by which they gain access to the blob.
165-
166-
Any user with ``reader`` privileges is able to read the blob attributes or data.
167-
A ``reader`` can read the ``owners`` and can only read the entry
168-
in the ``writers`` and/or ``readers`` by which they gain access to the blob.
169-
170-
Also see :doc:`Sharing Resources </guides/sharing>`.
171143
"""
172144

173145
_doc_type = "storage"
@@ -269,33 +241,6 @@ class Blob(CatalogObject):
269241
hash = TypedAttribute(
270242
str, doc="""str, optional: Content hash (MD5) for the blob."""
271243
)
272-
owners = ListAttribute(
273-
TypedAttribute(str),
274-
doc="""list(str), optional: User, group, or organization IDs that own this blob.
275-
276-
Defaults to [``user:current_user``, ``org:current_org``]. The owner can edit,
277-
delete, and change access to this blob. :ref:`See this note <blob_note>`.
278-
279-
*Filterable*.
280-
""",
281-
)
282-
readers = ListAttribute(
283-
TypedAttribute(str),
284-
doc="""list(str), optional: User, email, group, or organization IDs that can read this blob.
285-
286-
Will be empty by default. This attribute is only available in full to the `owners`
287-
of the blob. :ref:`See this note <blob_note>`.
288-
""",
289-
)
290-
writers = ListAttribute(
291-
TypedAttribute(str),
292-
doc="""list(str), optional: User, group, or organization IDs that can edit this blob.
293-
294-
Writers will also have read permission. Writers will be empty by default.
295-
See note below. This attribute is only available in full to the `owners` of the blob.
296-
:ref:`See this note <blob_note>`.
297-
""",
298-
)
299244

300245
@classmethod
301246
def namespace_id(cls, namespace_id, client=None):
@@ -1020,8 +965,9 @@ def _do_download(self, dest=None, range=None):
1020965
finally:
1021966
r.close()
1022967

1023-
@classmethod
1024-
def _cls_delete(cls, id, client=None):
968+
@hybridmethod
969+
@check_derived
970+
def delete(cls, id, client=None):
1025971
"""Delete the catalog object with the given `id`.
1026972
1027973
Parameters
@@ -1051,6 +997,10 @@ def _cls_delete(cls, id, client=None):
1051997
Example
1052998
-------
1053999
>>> Image.delete('my-image-id') # doctest: +SKIP
1000+
1001+
There is also an instance ``delete`` method that can be used to delete a blob.
1002+
It accepts no parameters and also returns a ``BlobDeletionTaskStatus``. Once
1003+
deleted, you cannot use the blob and should release any references.
10541004
"""
10551005
if client is None:
10561006
client = CatalogClient.get_default_client()
@@ -1062,7 +1012,9 @@ def _cls_delete(cls, id, client=None):
10621012
except NotFoundError:
10631013
return None
10641014

1065-
def _instance_delete(self):
1015+
@delete.instancemethod
1016+
@check_deleted
1017+
def delete(self):
10661018
"""Delete this catalog object from the Descartes Labs catalog.
10671019
10681020
Once deleted, you cannot use the catalog object and should release any

0 commit comments

Comments
 (0)