From 8efd2e5642d553e7a2c3f48ef1d041e63046e0b5 Mon Sep 17 00:00:00 2001 From: Wei Qi Lu Date: Mon, 29 Jun 2026 14:58:33 -0700 Subject: [PATCH 01/21] python(refactor): group access control APIs under client.access_control --- python/CHANGELOG.md | 6 +-- python/lib/sift_client/_tests/conftest.py | 3 +- .../sift_types/test_principal_attribute.py | 8 ++-- .../sift_types/test_resource_attribute.py | 18 +++---- python/lib/sift_client/client.py | 21 ++++---- .../sift_types/principal_attribute.py | 30 ++++++------ .../sift_types/resource_attribute.py | 30 ++++++------ python/lib/sift_client/util/util.py | 48 +++++++++++++++++-- 8 files changed, 101 insertions(+), 63 deletions(-) diff --git a/python/CHANGELOG.md b/python/CHANGELOG.md index 407e657a7..833f8c7db 100644 --- a/python/CHANGELOG.md +++ b/python/CHANGELOG.md @@ -9,14 +9,14 @@ This project adheres to [Semantic Versioning](http://semver.org/). #### Resource and principal attributes (ABAC) -Added a public API for attribute based access control (ABAC) attributes. `client.resource_attributes` manages attribute keys assigned to entities (assets, channels, runs), and `client.principal_attributes` manages attribute keys assigned to principals (users and user groups). Both are available synchronously and asynchronously via `client.async_`. +Added a public API for attribute based access control (ABAC) attributes under the `client.access_control` namespace. `client.access_control.resource_attributes` manages attribute keys assigned to entities (assets, channels, runs), and `client.access_control.principal_attributes` manages attribute keys assigned to principals (users and user groups). Both are available synchronously and asynchronously via `client.async_`. An attribute key is the entry point. Create or fetch a key, define its enum values, then assign a value to a set of entities: ```python from sift_client.sift_types import ResourceAttributeKeyType -key = client.resource_attributes.get_or_create_key("licenses", ResourceAttributeKeyType.SET_OF_ENUM) +key = client.access_control.resource_attributes.get_or_create_key("licenses", ResourceAttributeKeyType.SET_OF_ENUM) licenses = key.get_or_create_enum_values(["LICENSE_A", "LICENSE_B"]) key.assign_to(channels, value=licenses) ``` @@ -26,7 +26,7 @@ Principal attributes accept user IDs or email addresses, resolving emails to use ```python from sift_client.sift_types import PrincipalAttributeValueType -key = client.principal_attributes.get_or_create_key("licenses", PrincipalAttributeValueType.SET_OF_ENUM) +key = client.access_control.principal_attributes.get_or_create_key("licenses", PrincipalAttributeValueType.SET_OF_ENUM) licenses = key.get_or_create_enum_values(["LICENSE_A"]) key.assign_to(["user@example.com"], value=licenses) ``` diff --git a/python/lib/sift_client/_tests/conftest.py b/python/lib/sift_client/_tests/conftest.py index 41469dac5..c2c10f7fc 100644 --- a/python/lib/sift_client/_tests/conftest.py +++ b/python/lib/sift_client/_tests/conftest.py @@ -45,8 +45,7 @@ def mock_client(): client.channels = MagicMock() client.calculated_channels = MagicMock() client.rules = MagicMock() - client.resource_attributes = MagicMock() - client.principal_attributes = MagicMock() + client.access_control = MagicMock() client.tags = MagicMock() client.test_results = MagicMock() client.file_attachments = MagicMock() diff --git a/python/lib/sift_client/_tests/sift_types/test_principal_attribute.py b/python/lib/sift_client/_tests/sift_types/test_principal_attribute.py index 7eecfc70f..84ed17e7e 100644 --- a/python/lib/sift_client/_tests/sift_types/test_principal_attribute.py +++ b/python/lib/sift_client/_tests/sift_types/test_principal_attribute.py @@ -66,13 +66,13 @@ def test_archive_passes_principal_type_and_refreshes(self, mock_client): boolean_value=True, is_archived=True, ) - mock_client.principal_attributes.get_assignment.return_value = ( + mock_client.access_control.principal_attributes.get_assignment.return_value = ( PrincipalAttributeValue._from_proto(archived_proto) ) result = value.archive() - mock_client.principal_attributes.archive_assignments.assert_called_once_with( + mock_client.access_control.principal_attributes.archive_assignments.assert_called_once_with( [value], principal_type=PrincipalType.USER ) assert result is value @@ -104,11 +104,11 @@ class TestPrincipalAttributeKeyConvenience: def test_assign_to_defaults_to_user(self, mock_client): key = PrincipalAttributeKey._from_proto(_key_proto()) key._apply_client_to_instance(mock_client) - mock_client.principal_attributes.assign.return_value = [] + mock_client.access_control.principal_attributes.assign.return_value = [] key.assign_to(["u1"], value=["LIC_A"]) - mock_client.principal_attributes.assign.assert_called_once_with( + mock_client.access_control.principal_attributes.assign.assert_called_once_with( key, ["u1"], value=["LIC_A"], principal_type=PrincipalType.USER ) diff --git a/python/lib/sift_client/_tests/sift_types/test_resource_attribute.py b/python/lib/sift_client/_tests/sift_types/test_resource_attribute.py index 057282782..e2e5d66dc 100644 --- a/python/lib/sift_client/_tests/sift_types/test_resource_attribute.py +++ b/python/lib/sift_client/_tests/sift_types/test_resource_attribute.py @@ -118,13 +118,13 @@ def test_archive_refreshes_and_returns_self(self, mock_client): archived = ra.ResourceAttribute( resource_attribute_id="a1", resource_attribute_key_id="k1", is_archived=True ) - mock_client.resource_attributes.get_assignment.return_value = ResourceAttribute._from_proto( + mock_client.access_control.resource_attributes.get_assignment.return_value = ResourceAttribute._from_proto( archived ) result = attr.archive() - mock_client.resource_attributes.archive_assignments.assert_called_once_with([attr]) + mock_client.access_control.resource_attributes.archive_assignments.assert_called_once_with([attr]) assert result is attr assert attr.is_archived is True @@ -134,22 +134,22 @@ def test_archive_delegates_and_updates_in_place(self, mock_client): key = ResourceAttributeKey._from_proto(_key_proto()) key._apply_client_to_instance(mock_client) archived = ResourceAttributeKey._from_proto(_key_proto(is_archived=True)) - mock_client.resource_attributes.archive_key.return_value = archived + mock_client.access_control.resource_attributes.archive_key.return_value = archived result = key.archive() - mock_client.resource_attributes.archive_key.assert_called_once_with(key) + mock_client.access_control.resource_attributes.archive_key.assert_called_once_with(key) assert result is key assert key.is_archived is True def test_assign_to_delegates(self, mock_client): key = ResourceAttributeKey._from_proto(_key_proto()) key._apply_client_to_instance(mock_client) - mock_client.resource_attributes.assign.return_value = ["sentinel"] + mock_client.access_control.resource_attributes.assign.return_value = ["sentinel"] result = key.assign_to(["ch1"], value=["LIC_A"]) - mock_client.resource_attributes.assign.assert_called_once_with( + mock_client.access_control.resource_attributes.assign.assert_called_once_with( key, ["ch1"], value=["LIC_A"] ) assert result == ["sentinel"] @@ -157,7 +157,7 @@ def test_assign_to_delegates(self, mock_client): def test_check_archive_impact_delegates(self, mock_client): key = ResourceAttributeKey._from_proto(_key_proto()) key._apply_client_to_instance(mock_client) - mock_client.resource_attributes.check_key_archive_impact.return_value = 7 + mock_client.access_control.resource_attributes.check_key_archive_impact.return_value = 7 assert key.check_archive_impact() == 7 @@ -176,11 +176,11 @@ def test_archive_returns_migrated_count(self, mock_client): ) value = ResourceAttributeEnumValue._from_proto(proto) value._apply_client_to_instance(mock_client) - mock_client.resource_attributes.archive_enum_value.return_value = 3 + mock_client.access_control.resource_attributes.archive_enum_value.return_value = 3 migrated = value.archive(replacement="ev2") - mock_client.resource_attributes.archive_enum_value.assert_called_once_with( + mock_client.access_control.resource_attributes.archive_enum_value.assert_called_once_with( value, replacement="ev2" ) assert migrated == 3 diff --git a/python/lib/sift_client/client.py b/python/lib/sift_client/client.py index 2e2b64ffd..3c98a77f5 100644 --- a/python/lib/sift_client/client.py +++ b/python/lib/sift_client/client.py @@ -43,7 +43,7 @@ WithGrpcClient, WithRestClient, ) -from sift_client.util.util import AsyncAPIs +from sift_client.util.util import AccessControlAPI, AccessControlAPIAsync, AsyncAPIs class SiftClient( @@ -108,11 +108,8 @@ class SiftClient( runs: RunsAPI """Instance of the Runs API for making synchronous requests.""" - resource_attributes: ResourceAttributesAPI - """Instance of the Resource Attributes API for making synchronous requests.""" - - principal_attributes: PrincipalAttributesAPI - """Instance of the Principal Attributes API for making synchronous requests.""" + access_control: AccessControlAPI + """Namespace for the access-control APIs (resource and principal attributes).""" tags: TagsAPI """Instance of the Tags API for making synchronous requests.""" @@ -188,8 +185,10 @@ def __init__( self.rules = RulesAPI(self) self.reports = ReportsAPI(self) self.runs = RunsAPI(self) - self.resource_attributes = ResourceAttributesAPI(self) - self.principal_attributes = PrincipalAttributesAPI(self) + self.access_control = AccessControlAPI( + resource_attributes=ResourceAttributesAPI(self), + principal_attributes=PrincipalAttributesAPI(self), + ) self.tags = TagsAPI(self) self.test_results = TestResultsAPI(self) self.data_export = DataExportAPI(self) @@ -207,8 +206,10 @@ def __init__( reports=ReportsAPIAsync(self), rules=RulesAPIAsync(self), runs=RunsAPIAsync(self), - resource_attributes=ResourceAttributesAPIAsync(self), - principal_attributes=PrincipalAttributesAPIAsync(self), + access_control=AccessControlAPIAsync( + resource_attributes=ResourceAttributesAPIAsync(self), + principal_attributes=PrincipalAttributesAPIAsync(self), + ), tags=TagsAPIAsync(self), test_results=TestResultsAPIAsync(self), data_export=DataExportAPIAsync(self), diff --git a/python/lib/sift_client/sift_types/principal_attribute.py b/python/lib/sift_client/sift_types/principal_attribute.py index d555fd7df..6b36251ae 100644 --- a/python/lib/sift_client/sift_types/principal_attribute.py +++ b/python/lib/sift_client/sift_types/principal_attribute.py @@ -95,11 +95,11 @@ def archive(self, *, replacement: PrincipalAttributeEnumValue | str | None = Non Returns the migration count; it does not refresh this instance's ``is_archived``/``archived_date``. Re-fetch the enum value to observe those. """ - return self.client.principal_attributes.archive_enum_value(self, replacement=replacement) + return self.client.access_control.principal_attributes.archive_enum_value(self, replacement=replacement) def unarchive(self) -> PrincipalAttributeEnumValue: """Unarchive this enum value.""" - updated = self.client.principal_attributes.unarchive_enum_value(self) + updated = self.client.access_control.principal_attributes.unarchive_enum_value(self) self._update(updated) return self @@ -178,11 +178,11 @@ def _apply_client_to_instance(self, client: SiftClient) -> None: def archive(self) -> PrincipalAttributeValue: """Archive this assignment.""" - self.client.principal_attributes.archive_assignments( + self.client.access_control.principal_attributes.archive_assignments( [self], principal_type=self.principal_type ) self._update( - self.client.principal_attributes.get_assignment( + self.client.access_control.principal_attributes.get_assignment( assignment_id=self._id_or_error, principal_type=self.principal_type ) ) @@ -190,11 +190,11 @@ def archive(self) -> PrincipalAttributeValue: def unarchive(self) -> PrincipalAttributeValue: """Unarchive this assignment.""" - self.client.principal_attributes.unarchive_assignments( + self.client.access_control.principal_attributes.unarchive_assignments( [self], principal_type=self.principal_type ) self._update( - self.client.principal_attributes.get_assignment( + self.client.access_control.principal_attributes.get_assignment( assignment_id=self._id_or_error, principal_type=self.principal_type ) ) @@ -243,19 +243,19 @@ def create_enum_value( self, display_name: str, *, description: str = "" ) -> PrincipalAttributeEnumValue: """Create a single enum value for this key.""" - return self.client.principal_attributes.create_enum_value( + return self.client.access_control.principal_attributes.create_enum_value( self, display_name, description=description ) def get_or_create_enum_values(self, names: list[str]) -> list[PrincipalAttributeEnumValue]: """Get existing enum values by name, creating any that don't exist.""" - return self.client.principal_attributes.get_or_create_enum_values(self, names) + return self.client.access_control.principal_attributes.get_or_create_enum_values(self, names) def list_enum_values( self, *, include_archived: bool = False ) -> list[PrincipalAttributeEnumValue]: """List the enum values defined for this key.""" - return self.client.principal_attributes.list_enum_values( + return self.client.access_control.principal_attributes.list_enum_values( self, include_archived=include_archived ) @@ -276,7 +276,7 @@ def assign_to( Returns: The created assignments. """ - return self.client.principal_attributes.assign( + return self.client.access_control.principal_attributes.assign( self, principals, value=value, principal_type=principal_type ) @@ -284,7 +284,7 @@ def list_assignments( self, *, principal_type: PrincipalType = PrincipalType.USER, include_archived: bool = False ) -> list[PrincipalAttributeValue]: """List all assignments of this key for the given principal type.""" - return self.client.principal_attributes.list_assignments( + return self.client.access_control.principal_attributes.list_assignments( key=self, principal_type=principal_type, include_archived=include_archived ) @@ -292,7 +292,7 @@ def update( self, *, display_name: str | None = None, description: str | None = None ) -> PrincipalAttributeKey: """Update this key's display name or description.""" - updated = self.client.principal_attributes.update_key( + updated = self.client.access_control.principal_attributes.update_key( self, display_name=display_name, description=description ) self._update(updated) @@ -300,19 +300,19 @@ def update( def archive(self) -> PrincipalAttributeKey: """Archive this key. Cascades to its enum values and assignments.""" - updated = self.client.principal_attributes.archive_key(self) + updated = self.client.access_control.principal_attributes.archive_key(self) self._update(updated) return self def unarchive(self) -> PrincipalAttributeKey: """Unarchive this key.""" - updated = self.client.principal_attributes.unarchive_key(self) + updated = self.client.access_control.principal_attributes.unarchive_key(self) self._update(updated) return self def check_archive_impact(self) -> int: """Return the number of active assignments that archiving this key would affect.""" - return self.client.principal_attributes.check_key_archive_impact(self) + return self.client.access_control.principal_attributes.check_key_archive_impact(self) def __str__(self) -> str: return self.display_name diff --git a/python/lib/sift_client/sift_types/resource_attribute.py b/python/lib/sift_client/sift_types/resource_attribute.py index 26775aa83..020122eed 100644 --- a/python/lib/sift_client/sift_types/resource_attribute.py +++ b/python/lib/sift_client/sift_types/resource_attribute.py @@ -132,11 +132,11 @@ def archive(self, *, replacement: ResourceAttributeEnumValue | str | None = None Returns the migration count; it does not refresh this instance's ``is_archived``/``archived_date``. Re-fetch the enum value to observe those. """ - return self.client.resource_attributes.archive_enum_value(self, replacement=replacement) + return self.client.access_control.resource_attributes.archive_enum_value(self, replacement=replacement) def unarchive(self) -> ResourceAttributeEnumValue: """Unarchive this enum value.""" - updated = self.client.resource_attributes.unarchive_enum_value(self) + updated = self.client.access_control.resource_attributes.unarchive_enum_value(self) self._update(updated) return self @@ -217,17 +217,17 @@ def _apply_client_to_instance(self, client: SiftClient) -> None: def archive(self) -> ResourceAttribute: """Archive this assignment.""" - self.client.resource_attributes.archive_assignments([self]) + self.client.access_control.resource_attributes.archive_assignments([self]) self._update( - self.client.resource_attributes.get_assignment(assignment_id=self._id_or_error) + self.client.access_control.resource_attributes.get_assignment(assignment_id=self._id_or_error) ) return self def unarchive(self) -> ResourceAttribute: """Unarchive this assignment.""" - self.client.resource_attributes.unarchive_assignments([self]) + self.client.access_control.resource_attributes.unarchive_assignments([self]) self._update( - self.client.resource_attributes.get_assignment(assignment_id=self._id_or_error) + self.client.access_control.resource_attributes.get_assignment(assignment_id=self._id_or_error) ) return self @@ -274,19 +274,19 @@ def create_enum_value( self, display_name: str, *, description: str = "" ) -> ResourceAttributeEnumValue: """Create a single enum value for this key.""" - return self.client.resource_attributes.create_enum_value( + return self.client.access_control.resource_attributes.create_enum_value( self, display_name, description=description ) def get_or_create_enum_values(self, names: list[str]) -> list[ResourceAttributeEnumValue]: """Get existing enum values by name, creating any that don't exist.""" - return self.client.resource_attributes.get_or_create_enum_values(self, names) + return self.client.access_control.resource_attributes.get_or_create_enum_values(self, names) def list_enum_values( self, *, include_archived: bool = False ) -> list[ResourceAttributeEnumValue]: """List the enum values defined for this key.""" - return self.client.resource_attributes.list_enum_values( + return self.client.access_control.resource_attributes.list_enum_values( self, include_archived=include_archived ) @@ -304,11 +304,11 @@ def assign_to(self, entities, *, value) -> list[ResourceAttribute]: Returns: The created assignments. """ - return self.client.resource_attributes.assign(self, entities, value=value) + return self.client.access_control.resource_attributes.assign(self, entities, value=value) def list_assignments(self, *, include_archived: bool = False) -> list[ResourceAttribute]: """List all assignments of this key.""" - return self.client.resource_attributes.list_assignments( + return self.client.access_control.resource_attributes.list_assignments( key=self, include_archived=include_archived ) @@ -316,7 +316,7 @@ def update( self, *, display_name: str | None = None, description: str | None = None ) -> ResourceAttributeKey: """Update this key's display name or description.""" - updated = self.client.resource_attributes.update_key( + updated = self.client.access_control.resource_attributes.update_key( self, display_name=display_name, description=description ) self._update(updated) @@ -324,19 +324,19 @@ def update( def archive(self) -> ResourceAttributeKey: """Archive this key. Cascades to its enum values and assignments.""" - updated = self.client.resource_attributes.archive_key(self) + updated = self.client.access_control.resource_attributes.archive_key(self) self._update(updated) return self def unarchive(self) -> ResourceAttributeKey: """Unarchive this key.""" - updated = self.client.resource_attributes.unarchive_key(self) + updated = self.client.access_control.resource_attributes.unarchive_key(self) self._update(updated) return self def check_archive_impact(self) -> int: """Return the number of active assignments that archiving this key would affect.""" - return self.client.resource_attributes.check_key_archive_impact(self) + return self.client.access_control.resource_attributes.check_key_archive_impact(self) def __str__(self) -> str: return self.display_name diff --git a/python/lib/sift_client/util/util.py b/python/lib/sift_client/util/util.py index 599064e83..96209386a 100644 --- a/python/lib/sift_client/util/util.py +++ b/python/lib/sift_client/util/util.py @@ -13,8 +13,10 @@ IngestionAPIAsync, JobsAPIAsync, PingAPIAsync, + PrincipalAttributesAPI, PrincipalAttributesAPIAsync, ReportsAPIAsync, + ResourceAttributesAPI, ResourceAttributesAPIAsync, RulesAPIAsync, RunsAPIAsync, @@ -23,6 +25,45 @@ ) +class AccessControlAPI: + """Access-control namespace. Groups the ABAC APIs; roles, policies, and user groups + will live here as they are added.""" + + resource_attributes: ResourceAttributesAPI + """Attribute keys assigned to entities (assets, channels, runs).""" + + principal_attributes: PrincipalAttributesAPI + """Attribute keys assigned to principals (users, user groups).""" + + def __init__( + self, + *, + resource_attributes: ResourceAttributesAPI, + principal_attributes: PrincipalAttributesAPI, + ): + self.resource_attributes = resource_attributes + self.principal_attributes = principal_attributes + + +class AccessControlAPIAsync: + """Asynchronous counterpart to `AccessControlAPI`.""" + + resource_attributes: ResourceAttributesAPIAsync + """Attribute keys assigned to entities (assets, channels, runs).""" + + principal_attributes: PrincipalAttributesAPIAsync + """Attribute keys assigned to principals (users, user groups).""" + + def __init__( + self, + *, + resource_attributes: ResourceAttributesAPIAsync, + principal_attributes: PrincipalAttributesAPIAsync, + ): + self.resource_attributes = resource_attributes + self.principal_attributes = principal_attributes + + class AsyncAPIs(NamedTuple): """Simple accessor for the asynchronous APIs, still uses the SiftClient instance.""" @@ -56,11 +97,8 @@ class AsyncAPIs(NamedTuple): rules: RulesAPIAsync """Instance of the Rules API for making asynchronous requests.""" - resource_attributes: ResourceAttributesAPIAsync - """Instance of the Resource Attributes API for making asynchronous requests.""" - - principal_attributes: PrincipalAttributesAPIAsync - """Instance of the Principal Attributes API for making asynchronous requests.""" + access_control: AccessControlAPIAsync + """Namespace for the access-control APIs (resource and principal attributes).""" tags: TagsAPIAsync """Instance of the Tags API for making asynchronous requests.""" From 0038f966a1e4d11b860ed8ced56b818634552a6b Mon Sep 17 00:00:00 2001 From: Wei Qi Lu Date: Mon, 29 Jun 2026 15:04:58 -0700 Subject: [PATCH 02/21] lint --- .../_tests/sift_types/test_resource_attribute.py | 8 +++++--- .../sift_client/sift_types/principal_attribute.py | 8 ++++++-- .../lib/sift_client/sift_types/resource_attribute.py | 12 +++++++++--- python/lib/sift_client/util/util.py | 5 ++++- 4 files changed, 24 insertions(+), 9 deletions(-) diff --git a/python/lib/sift_client/_tests/sift_types/test_resource_attribute.py b/python/lib/sift_client/_tests/sift_types/test_resource_attribute.py index e2e5d66dc..5a7470816 100644 --- a/python/lib/sift_client/_tests/sift_types/test_resource_attribute.py +++ b/python/lib/sift_client/_tests/sift_types/test_resource_attribute.py @@ -118,13 +118,15 @@ def test_archive_refreshes_and_returns_self(self, mock_client): archived = ra.ResourceAttribute( resource_attribute_id="a1", resource_attribute_key_id="k1", is_archived=True ) - mock_client.access_control.resource_attributes.get_assignment.return_value = ResourceAttribute._from_proto( - archived + mock_client.access_control.resource_attributes.get_assignment.return_value = ( + ResourceAttribute._from_proto(archived) ) result = attr.archive() - mock_client.access_control.resource_attributes.archive_assignments.assert_called_once_with([attr]) + mock_client.access_control.resource_attributes.archive_assignments.assert_called_once_with( + [attr] + ) assert result is attr assert attr.is_archived is True diff --git a/python/lib/sift_client/sift_types/principal_attribute.py b/python/lib/sift_client/sift_types/principal_attribute.py index 6b36251ae..16204bdae 100644 --- a/python/lib/sift_client/sift_types/principal_attribute.py +++ b/python/lib/sift_client/sift_types/principal_attribute.py @@ -95,7 +95,9 @@ def archive(self, *, replacement: PrincipalAttributeEnumValue | str | None = Non Returns the migration count; it does not refresh this instance's ``is_archived``/``archived_date``. Re-fetch the enum value to observe those. """ - return self.client.access_control.principal_attributes.archive_enum_value(self, replacement=replacement) + return self.client.access_control.principal_attributes.archive_enum_value( + self, replacement=replacement + ) def unarchive(self) -> PrincipalAttributeEnumValue: """Unarchive this enum value.""" @@ -249,7 +251,9 @@ def create_enum_value( def get_or_create_enum_values(self, names: list[str]) -> list[PrincipalAttributeEnumValue]: """Get existing enum values by name, creating any that don't exist.""" - return self.client.access_control.principal_attributes.get_or_create_enum_values(self, names) + return self.client.access_control.principal_attributes.get_or_create_enum_values( + self, names + ) def list_enum_values( self, *, include_archived: bool = False diff --git a/python/lib/sift_client/sift_types/resource_attribute.py b/python/lib/sift_client/sift_types/resource_attribute.py index 020122eed..eb754cbc9 100644 --- a/python/lib/sift_client/sift_types/resource_attribute.py +++ b/python/lib/sift_client/sift_types/resource_attribute.py @@ -132,7 +132,9 @@ def archive(self, *, replacement: ResourceAttributeEnumValue | str | None = None Returns the migration count; it does not refresh this instance's ``is_archived``/``archived_date``. Re-fetch the enum value to observe those. """ - return self.client.access_control.resource_attributes.archive_enum_value(self, replacement=replacement) + return self.client.access_control.resource_attributes.archive_enum_value( + self, replacement=replacement + ) def unarchive(self) -> ResourceAttributeEnumValue: """Unarchive this enum value.""" @@ -219,7 +221,9 @@ def archive(self) -> ResourceAttribute: """Archive this assignment.""" self.client.access_control.resource_attributes.archive_assignments([self]) self._update( - self.client.access_control.resource_attributes.get_assignment(assignment_id=self._id_or_error) + self.client.access_control.resource_attributes.get_assignment( + assignment_id=self._id_or_error + ) ) return self @@ -227,7 +231,9 @@ def unarchive(self) -> ResourceAttribute: """Unarchive this assignment.""" self.client.access_control.resource_attributes.unarchive_assignments([self]) self._update( - self.client.access_control.resource_attributes.get_assignment(assignment_id=self._id_or_error) + self.client.access_control.resource_attributes.get_assignment( + assignment_id=self._id_or_error + ) ) return self diff --git a/python/lib/sift_client/util/util.py b/python/lib/sift_client/util/util.py index 96209386a..7ff65cf9b 100644 --- a/python/lib/sift_client/util/util.py +++ b/python/lib/sift_client/util/util.py @@ -27,7 +27,8 @@ class AccessControlAPI: """Access-control namespace. Groups the ABAC APIs; roles, policies, and user groups - will live here as they are added.""" + will live here as they are added. + """ resource_attributes: ResourceAttributesAPI """Attribute keys assigned to entities (assets, channels, runs).""" @@ -41,6 +42,7 @@ def __init__( resource_attributes: ResourceAttributesAPI, principal_attributes: PrincipalAttributesAPI, ): + """Initialize the access-control namespace with its sub-APIs.""" self.resource_attributes = resource_attributes self.principal_attributes = principal_attributes @@ -60,6 +62,7 @@ def __init__( resource_attributes: ResourceAttributesAPIAsync, principal_attributes: PrincipalAttributesAPIAsync, ): + """Initialize the access-control namespace with its sub-APIs.""" self.resource_attributes = resource_attributes self.principal_attributes = principal_attributes From 4211b0fa017a357d6e2c85ae62ccae4af5fe8ad5 Mon Sep 17 00:00:00 2001 From: Wei Qi Lu Date: Mon, 29 Jun 2026 15:18:22 -0700 Subject: [PATCH 03/21] nest attributes in resources --- .../resources/test_principal_attributes.py | 2 +- .../resources/test_resource_attributes.py | 2 +- python/lib/sift_client/client.py | 3 +- python/lib/sift_client/resources/__init__.py | 4 +- .../resources/access_control/__init__.py | 64 +++++++++++++++++++ .../principal_attributes.py | 0 .../resource_attributes.py | 0 python/lib/sift_client/util/util.py | 47 +------------- 8 files changed, 71 insertions(+), 51 deletions(-) create mode 100644 python/lib/sift_client/resources/access_control/__init__.py rename python/lib/sift_client/resources/{ => access_control}/principal_attributes.py (100%) rename python/lib/sift_client/resources/{ => access_control}/resource_attributes.py (100%) diff --git a/python/lib/sift_client/_tests/resources/test_principal_attributes.py b/python/lib/sift_client/_tests/resources/test_principal_attributes.py index f194b58ec..c08788bdf 100644 --- a/python/lib/sift_client/_tests/resources/test_principal_attributes.py +++ b/python/lib/sift_client/_tests/resources/test_principal_attributes.py @@ -8,7 +8,7 @@ import pytest from sift.principal_attributes.v1 import principal_attributes_pb2 as pa -from sift_client.resources.principal_attributes import PrincipalAttributesAPIAsync +from sift_client.resources.access_control.principal_attributes import PrincipalAttributesAPIAsync from sift_client.sift_types.principal_attribute import ( PrincipalAttributeKey, PrincipalType, diff --git a/python/lib/sift_client/_tests/resources/test_resource_attributes.py b/python/lib/sift_client/_tests/resources/test_resource_attributes.py index 777459a6d..6ae371c7e 100644 --- a/python/lib/sift_client/_tests/resources/test_resource_attributes.py +++ b/python/lib/sift_client/_tests/resources/test_resource_attributes.py @@ -10,7 +10,7 @@ import pytest from sift.resource_attribute.v1 import resource_attribute_pb2 as ra -from sift_client.resources.resource_attributes import ResourceAttributesAPIAsync +from sift_client.resources.access_control.resource_attributes import ResourceAttributesAPIAsync from sift_client.sift_types.resource_attribute import ( ResourceAttributeEntity, ResourceAttributeEnumValue, diff --git a/python/lib/sift_client/client.py b/python/lib/sift_client/client.py index 3c98a77f5..dd80f9320 100644 --- a/python/lib/sift_client/client.py +++ b/python/lib/sift_client/client.py @@ -34,6 +34,7 @@ TestResultsAPI, TestResultsAPIAsync, ) +from sift_client.resources.access_control import AccessControlAPI, AccessControlAPIAsync from sift_client.transport import ( GrpcClient, GrpcConfig, @@ -43,7 +44,7 @@ WithGrpcClient, WithRestClient, ) -from sift_client.util.util import AccessControlAPI, AccessControlAPIAsync, AsyncAPIs +from sift_client.util.util import AsyncAPIs class SiftClient( diff --git a/python/lib/sift_client/resources/__init__.py b/python/lib/sift_client/resources/__init__.py index ca3d1979f..965336d1c 100644 --- a/python/lib/sift_client/resources/__init__.py +++ b/python/lib/sift_client/resources/__init__.py @@ -163,9 +163,9 @@ async def main(): ) from sift_client.resources.jobs import JobsAPIAsync from sift_client.resources.ping import PingAPIAsync -from sift_client.resources.principal_attributes import PrincipalAttributesAPIAsync +from sift_client.resources.access_control.principal_attributes import PrincipalAttributesAPIAsync +from sift_client.resources.access_control.resource_attributes import ResourceAttributesAPIAsync from sift_client.resources.reports import ReportsAPIAsync -from sift_client.resources.resource_attributes import ResourceAttributesAPIAsync from sift_client.resources.rules import RulesAPIAsync from sift_client.resources.runs import RunsAPIAsync from sift_client.resources.tags import TagsAPIAsync diff --git a/python/lib/sift_client/resources/access_control/__init__.py b/python/lib/sift_client/resources/access_control/__init__.py new file mode 100644 index 000000000..a73efa777 --- /dev/null +++ b/python/lib/sift_client/resources/access_control/__init__.py @@ -0,0 +1,64 @@ +"""Access-control APIs. + +Groups the ABAC sub-APIs (resource and principal attributes); roles, policies, and +user groups will live here as they are added. The namespace is exposed on the client +as ``client.access_control`` (and ``client.async_.access_control``). +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from sift_client.resources.access_control.principal_attributes import ( + PrincipalAttributesAPIAsync, + ) + from sift_client.resources.access_control.resource_attributes import ( + ResourceAttributesAPIAsync, + ) + from sift_client.resources.sync_stubs import ( + PrincipalAttributesAPI, + ResourceAttributesAPI, + ) + + +class AccessControlAPI: + """Access-control namespace. Groups the ABAC APIs; roles, policies, and user groups + will live here as they are added. + """ + + resource_attributes: ResourceAttributesAPI + """Attribute keys assigned to entities (assets, channels, runs).""" + + principal_attributes: PrincipalAttributesAPI + """Attribute keys assigned to principals (users, user groups).""" + + def __init__( + self, + *, + resource_attributes: ResourceAttributesAPI, + principal_attributes: PrincipalAttributesAPI, + ): + """Initialize the access-control namespace with its sub-APIs.""" + self.resource_attributes = resource_attributes + self.principal_attributes = principal_attributes + + +class AccessControlAPIAsync: + """Asynchronous counterpart to `AccessControlAPI`.""" + + resource_attributes: ResourceAttributesAPIAsync + """Attribute keys assigned to entities (assets, channels, runs).""" + + principal_attributes: PrincipalAttributesAPIAsync + """Attribute keys assigned to principals (users, user groups).""" + + def __init__( + self, + *, + resource_attributes: ResourceAttributesAPIAsync, + principal_attributes: PrincipalAttributesAPIAsync, + ): + """Initialize the access-control namespace with its sub-APIs.""" + self.resource_attributes = resource_attributes + self.principal_attributes = principal_attributes diff --git a/python/lib/sift_client/resources/principal_attributes.py b/python/lib/sift_client/resources/access_control/principal_attributes.py similarity index 100% rename from python/lib/sift_client/resources/principal_attributes.py rename to python/lib/sift_client/resources/access_control/principal_attributes.py diff --git a/python/lib/sift_client/resources/resource_attributes.py b/python/lib/sift_client/resources/access_control/resource_attributes.py similarity index 100% rename from python/lib/sift_client/resources/resource_attributes.py rename to python/lib/sift_client/resources/access_control/resource_attributes.py diff --git a/python/lib/sift_client/util/util.py b/python/lib/sift_client/util/util.py index 7ff65cf9b..da4484a79 100644 --- a/python/lib/sift_client/util/util.py +++ b/python/lib/sift_client/util/util.py @@ -13,58 +13,13 @@ IngestionAPIAsync, JobsAPIAsync, PingAPIAsync, - PrincipalAttributesAPI, - PrincipalAttributesAPIAsync, ReportsAPIAsync, - ResourceAttributesAPI, - ResourceAttributesAPIAsync, RulesAPIAsync, RunsAPIAsync, TagsAPIAsync, TestResultsAPIAsync, ) - - -class AccessControlAPI: - """Access-control namespace. Groups the ABAC APIs; roles, policies, and user groups - will live here as they are added. - """ - - resource_attributes: ResourceAttributesAPI - """Attribute keys assigned to entities (assets, channels, runs).""" - - principal_attributes: PrincipalAttributesAPI - """Attribute keys assigned to principals (users, user groups).""" - - def __init__( - self, - *, - resource_attributes: ResourceAttributesAPI, - principal_attributes: PrincipalAttributesAPI, - ): - """Initialize the access-control namespace with its sub-APIs.""" - self.resource_attributes = resource_attributes - self.principal_attributes = principal_attributes - - -class AccessControlAPIAsync: - """Asynchronous counterpart to `AccessControlAPI`.""" - - resource_attributes: ResourceAttributesAPIAsync - """Attribute keys assigned to entities (assets, channels, runs).""" - - principal_attributes: PrincipalAttributesAPIAsync - """Attribute keys assigned to principals (users, user groups).""" - - def __init__( - self, - *, - resource_attributes: ResourceAttributesAPIAsync, - principal_attributes: PrincipalAttributesAPIAsync, - ): - """Initialize the access-control namespace with its sub-APIs.""" - self.resource_attributes = resource_attributes - self.principal_attributes = principal_attributes + from sift_client.resources.access_control import AccessControlAPIAsync class AsyncAPIs(NamedTuple): From 53b9168bd9a8affcd583f79076298fb118e3a7e6 Mon Sep 17 00:00:00 2001 From: Wei Qi Lu Date: Mon, 29 Jun 2026 15:55:20 -0700 Subject: [PATCH 04/21] clarify access control namespace --- python/CHANGELOG.md | 2 +- python/lib/sift_client/client.py | 2 +- .../resources/access_control/__init__.py | 25 ++++++++----------- python/lib/sift_client/util/util.py | 2 +- 4 files changed, 14 insertions(+), 17 deletions(-) diff --git a/python/CHANGELOG.md b/python/CHANGELOG.md index 833f8c7db..7ac47d983 100644 --- a/python/CHANGELOG.md +++ b/python/CHANGELOG.md @@ -9,7 +9,7 @@ This project adheres to [Semantic Versioning](http://semver.org/). #### Resource and principal attributes (ABAC) -Added a public API for attribute based access control (ABAC) attributes under the `client.access_control` namespace. `client.access_control.resource_attributes` manages attribute keys assigned to entities (assets, channels, runs), and `client.access_control.principal_attributes` manages attribute keys assigned to principals (users and user groups). Both are available synchronously and asynchronously via `client.async_`. +Added a public API for attribute based access control (ABAC) attributes under `client.access_control`. Use `client.access_control.resource_attributes` for assets, channels, and runs. Use `client.access_control.principal_attributes` for users and user groups. Async APIs are available under `client.async_.access_control`. An attribute key is the entry point. Create or fetch a key, define its enum values, then assign a value to a set of entities: diff --git a/python/lib/sift_client/client.py b/python/lib/sift_client/client.py index dd80f9320..29895b4ca 100644 --- a/python/lib/sift_client/client.py +++ b/python/lib/sift_client/client.py @@ -110,7 +110,7 @@ class SiftClient( """Instance of the Runs API for making synchronous requests.""" access_control: AccessControlAPI - """Namespace for the access-control APIs (resource and principal attributes).""" + """Namespace for access-control APIs.""" tags: TagsAPI """Instance of the Tags API for making synchronous requests.""" diff --git a/python/lib/sift_client/resources/access_control/__init__.py b/python/lib/sift_client/resources/access_control/__init__.py index a73efa777..765c20d0a 100644 --- a/python/lib/sift_client/resources/access_control/__init__.py +++ b/python/lib/sift_client/resources/access_control/__init__.py @@ -1,8 +1,7 @@ -"""Access-control APIs. +"""Access-control API namespace. -Groups the ABAC sub-APIs (resource and principal attributes); roles, policies, and -user groups will live here as they are added. The namespace is exposed on the client -as ``client.access_control`` (and ``client.async_.access_control``). +Use ``client.access_control`` for synchronous APIs and +``client.async_.access_control`` for asynchronous APIs. """ from __future__ import annotations @@ -23,15 +22,13 @@ class AccessControlAPI: - """Access-control namespace. Groups the ABAC APIs; roles, policies, and user groups - will live here as they are added. - """ + """Namespace for access-control APIs.""" resource_attributes: ResourceAttributesAPI - """Attribute keys assigned to entities (assets, channels, runs).""" + """Manage ABAC attributes for assets, channels, and runs.""" principal_attributes: PrincipalAttributesAPI - """Attribute keys assigned to principals (users, user groups).""" + """Manage ABAC attributes for users and user groups.""" def __init__( self, @@ -39,19 +36,19 @@ def __init__( resource_attributes: ResourceAttributesAPI, principal_attributes: PrincipalAttributesAPI, ): - """Initialize the access-control namespace with its sub-APIs.""" + """Initialize the namespace.""" self.resource_attributes = resource_attributes self.principal_attributes = principal_attributes class AccessControlAPIAsync: - """Asynchronous counterpart to `AccessControlAPI`.""" + """Namespace for async access-control APIs.""" resource_attributes: ResourceAttributesAPIAsync - """Attribute keys assigned to entities (assets, channels, runs).""" + """Manage ABAC attributes for assets, channels, and runs.""" principal_attributes: PrincipalAttributesAPIAsync - """Attribute keys assigned to principals (users, user groups).""" + """Manage ABAC attributes for users and user groups.""" def __init__( self, @@ -59,6 +56,6 @@ def __init__( resource_attributes: ResourceAttributesAPIAsync, principal_attributes: PrincipalAttributesAPIAsync, ): - """Initialize the access-control namespace with its sub-APIs.""" + """Initialize the namespace.""" self.resource_attributes = resource_attributes self.principal_attributes = principal_attributes diff --git a/python/lib/sift_client/util/util.py b/python/lib/sift_client/util/util.py index da4484a79..35c17f748 100644 --- a/python/lib/sift_client/util/util.py +++ b/python/lib/sift_client/util/util.py @@ -56,7 +56,7 @@ class AsyncAPIs(NamedTuple): """Instance of the Rules API for making asynchronous requests.""" access_control: AccessControlAPIAsync - """Namespace for the access-control APIs (resource and principal attributes).""" + """Namespace for access-control APIs.""" tags: TagsAPIAsync """Instance of the Tags API for making asynchronous requests.""" From 28671384cc908e2781c768223a354f4f63736aa0 Mon Sep 17 00:00:00 2001 From: Wei Qi Lu Date: Mon, 29 Jun 2026 17:08:36 -0700 Subject: [PATCH 05/21] update access control api docs and resource handling --- python/CHANGELOG.md | 14 +++- .../resources/test_resource_attributes.py | 29 +++++++ .../sift_types/test_resource_attribute.py | 6 +- python/lib/sift_client/client.py | 2 +- .../resources/access_control/__init__.py | 15 ++-- .../access_control/principal_attributes.py | 31 ++++---- .../access_control/resource_attributes.py | 79 ++++++++++--------- .../resources/sync_stubs/__init__.pyi | 48 ++++++----- .../sift_types/principal_attribute.py | 22 ++++-- .../sift_types/resource_attribute.py | 43 ++++++---- python/lib/sift_client/util/util.py | 2 +- 11 files changed, 180 insertions(+), 111 deletions(-) diff --git a/python/CHANGELOG.md b/python/CHANGELOG.md index 7ac47d983..afa240f25 100644 --- a/python/CHANGELOG.md +++ b/python/CHANGELOG.md @@ -9,14 +9,17 @@ This project adheres to [Semantic Versioning](http://semver.org/). #### Resource and principal attributes (ABAC) -Added a public API for attribute based access control (ABAC) attributes under `client.access_control`. Use `client.access_control.resource_attributes` for assets, channels, and runs. Use `client.access_control.principal_attributes` for users and user groups. Async APIs are available under `client.async_.access_control`. +Added a public API for attribute-based access control (ABAC) attributes under `client.access_control`. Resource attributes describe the Sift objects an access decision applies to, such as assets, channels, and runs. Principal attributes describe the users or groups an access decision applies to. Async APIs are available under `client.async_.access_control`. -An attribute key is the entry point. Create or fetch a key, define its enum values, then assign a value to a set of entities: +An attribute key is the entry point. Create or fetch a key, define its enum values, then assign a value to a set of resources: ```python from sift_client.sift_types import ResourceAttributeKeyType -key = client.access_control.resource_attributes.get_or_create_key("licenses", ResourceAttributeKeyType.SET_OF_ENUM) +key = client.access_control.resource_attributes.get_or_create_key( + "licenses", + ResourceAttributeKeyType.SET_OF_ENUM, +) licenses = key.get_or_create_enum_values(["LICENSE_A", "LICENSE_B"]) key.assign_to(channels, value=licenses) ``` @@ -26,7 +29,10 @@ Principal attributes accept user IDs or email addresses, resolving emails to use ```python from sift_client.sift_types import PrincipalAttributeValueType -key = client.access_control.principal_attributes.get_or_create_key("licenses", PrincipalAttributeValueType.SET_OF_ENUM) +key = client.access_control.principal_attributes.get_or_create_key( + "licenses", + PrincipalAttributeValueType.SET_OF_ENUM, +) licenses = key.get_or_create_enum_values(["LICENSE_A"]) key.assign_to(["user@example.com"], value=licenses) ``` diff --git a/python/lib/sift_client/_tests/resources/test_resource_attributes.py b/python/lib/sift_client/_tests/resources/test_resource_attributes.py index 6ae371c7e..8d5640d10 100644 --- a/python/lib/sift_client/_tests/resources/test_resource_attributes.py +++ b/python/lib/sift_client/_tests/resources/test_resource_attributes.py @@ -13,6 +13,7 @@ from sift_client.resources.access_control.resource_attributes import ResourceAttributesAPIAsync from sift_client.sift_types.resource_attribute import ( ResourceAttributeEntity, + ResourceAttributeEntityType, ResourceAttributeEnumValue, ResourceAttributeKey, ResourceAttributeKeyType, @@ -128,6 +129,34 @@ async def test_resolves_domain_objects_to_entities(self): assert entities[0].entity_type == ResourceAttributeEntity.for_asset("a1").entity_type assert entities[0].entity_id == "a1" + @pytest.mark.asyncio + async def test_rejects_resource_entity_types_outside_current_supported_targets(self): + api = _api() + api._low_level_client.batch_create_resource_attributes = AsyncMock(return_value=[]) + unsupported = ResourceAttributeEntity( + entity_id="unknown", + entity_type=ResourceAttributeEntityType.UNSPECIFIED, + ) + + with pytest.raises(ValueError, match="currently support assets, channels, and runs"): + await api.assign(_key(), [unsupported], value=["e_a"]) + + api._low_level_client.batch_create_resource_attributes.assert_not_called() + + @pytest.mark.asyncio + async def test_rejects_resource_assignment_filters_outside_current_supported_targets(self): + api = _api() + api._low_level_client.list_all_resource_attributes_by_entity = AsyncMock(return_value=[]) + unsupported = ResourceAttributeEntity( + entity_id="unknown", + entity_type=ResourceAttributeEntityType.UNSPECIFIED, + ) + + with pytest.raises(ValueError, match="currently support assets, channels, and runs"): + await api.list_assignments(resource=unsupported) + + api._low_level_client.list_all_resource_attributes_by_entity.assert_not_called() + def _asset_proto(): from sift.assets.v1.assets_pb2 import Asset as AssetProto diff --git a/python/lib/sift_client/_tests/sift_types/test_resource_attribute.py b/python/lib/sift_client/_tests/sift_types/test_resource_attribute.py index 5a7470816..db07bf964 100644 --- a/python/lib/sift_client/_tests/sift_types/test_resource_attribute.py +++ b/python/lib/sift_client/_tests/sift_types/test_resource_attribute.py @@ -149,10 +149,12 @@ def test_assign_to_delegates(self, mock_client): key._apply_client_to_instance(mock_client) mock_client.access_control.resource_attributes.assign.return_value = ["sentinel"] - result = key.assign_to(["ch1"], value=["LIC_A"]) + resource = ResourceAttributeEntity.for_channel("ch1") + + result = key.assign_to([resource], value=["LIC_A"]) mock_client.access_control.resource_attributes.assign.assert_called_once_with( - key, ["ch1"], value=["LIC_A"] + key, [resource], value=["LIC_A"] ) assert result == ["sentinel"] diff --git a/python/lib/sift_client/client.py b/python/lib/sift_client/client.py index 29895b4ca..ca73ba714 100644 --- a/python/lib/sift_client/client.py +++ b/python/lib/sift_client/client.py @@ -110,7 +110,7 @@ class SiftClient( """Instance of the Runs API for making synchronous requests.""" access_control: AccessControlAPI - """Namespace for access-control APIs.""" + """Access-control APIs for configuring who can access what in Sift.""" tags: TagsAPI """Instance of the Tags API for making synchronous requests.""" diff --git a/python/lib/sift_client/resources/access_control/__init__.py b/python/lib/sift_client/resources/access_control/__init__.py index 765c20d0a..da8d45b44 100644 --- a/python/lib/sift_client/resources/access_control/__init__.py +++ b/python/lib/sift_client/resources/access_control/__init__.py @@ -1,5 +1,8 @@ """Access-control API namespace. +Access-control APIs configure who can access what in Sift. In these APIs, a +principal is the "who" and a resource is the "what" that access applies to. + Use ``client.access_control`` for synchronous APIs and ``client.async_.access_control`` for asynchronous APIs. """ @@ -22,13 +25,13 @@ class AccessControlAPI: - """Namespace for access-control APIs.""" + """Access-control APIs for configuring who can access what in Sift.""" resource_attributes: ResourceAttributesAPI - """Manage ABAC attributes for assets, channels, and runs.""" + """Manage attributes on supported resources, such as assets, channels, and runs.""" principal_attributes: PrincipalAttributesAPI - """Manage ABAC attributes for users and user groups.""" + """Manage attributes on principals such as users and user groups.""" def __init__( self, @@ -42,13 +45,13 @@ def __init__( class AccessControlAPIAsync: - """Namespace for async access-control APIs.""" + """Async access-control APIs for configuring who can access what in Sift.""" resource_attributes: ResourceAttributesAPIAsync - """Manage ABAC attributes for assets, channels, and runs.""" + """Manage attributes on supported resources, such as assets, channels, and runs.""" principal_attributes: PrincipalAttributesAPIAsync - """Manage ABAC attributes for users and user groups.""" + """Manage attributes on principals such as users and user groups.""" def __init__( self, diff --git a/python/lib/sift_client/resources/access_control/principal_attributes.py b/python/lib/sift_client/resources/access_control/principal_attributes.py index 06e24fedc..9b52e8e0a 100644 --- a/python/lib/sift_client/resources/access_control/principal_attributes.py +++ b/python/lib/sift_client/resources/access_control/principal_attributes.py @@ -45,11 +45,14 @@ def _chunks(items: list[Any], size: int): class PrincipalAttributesAPIAsync(ResourceBase): - """High-level API for principal attributes (ABAC). + """High-level API for principal attributes. - Principal attributes assign attribute keys to principals (users or user groups). The - attribute key is the entry point: enum values and assignments are managed through - methods on a key, or through the corresponding methods here. + Principal attributes describe the users or groups an access decision applies to. + A principal is the "who" in an access decision, such as a user or user group. + + Create or fetch an attribute key, define enum values when the key uses them, then + assign a value to principals. User principals accept either user IDs or email + addresses; user-group principals use user-group IDs. """ def __init__(self, sift_client: SiftClient): @@ -63,8 +66,6 @@ def __init__(self, sift_client: SiftClient): grpc_client=self.client.grpc_client ) - # ───────── Keys ───────── - async def get_key(self, *, key_id: str) -> PrincipalAttributeKey: """Get a principal attribute key by ID.""" key = await self._low_level_client.get_key(key_id) @@ -189,8 +190,6 @@ async def check_key_archive_impact(self, key: str | PrincipalAttributeKey) -> in key_id = key._id_or_error if isinstance(key, PrincipalAttributeKey) else key return await self._low_level_client.check_key_archive_impact(key_id) - # ───────── Enum values ───────── - async def create_enum_value( self, key: str | PrincipalAttributeKey, @@ -280,8 +279,6 @@ async def unarchive_enum_value( value = await self._low_level_client.get_enum_value(enum_value_id) return self._apply_client_to_instance(value) - # ───────── Assignments (values) ───────── - async def assign( self, key: PrincipalAttributeKey, @@ -290,16 +287,17 @@ async def assign( value: Any, principal_type: PrincipalType = PrincipalType.USER, ) -> list[PrincipalAttributeValue]: - """Assign a value to principals for a key. + """Assign a key's value to principals. Args: key: The key to assign. Its ``value_type`` determines how ``value`` is interpreted. - principals: Principal IDs. For ``USER`` principals, an entry containing ``@`` is - treated as an email and resolved to a user ID. + principals: Principal IDs. For ``USER`` principals, entries containing ``@`` are + treated as email addresses and resolved to user IDs. value: For ``SET_OF_ENUM``, a list of enum values (or their IDs) that becomes the full set on each principal; for ``ENUM``, a single enum value; for ``BOOLEAN``, a bool; for ``NUMBER``, an int. - principal_type: The kind of principal being assigned to. Defaults to ``USER``. + principal_type: The kind of principal being assigned to. Defaults to ``USER``. Use + ``PrincipalType.USER_GROUP`` when assigning to user groups. Returns: The created assignments. @@ -348,7 +346,8 @@ async def list_assignments( Args: key: Filter to assignments of this key. - principal: Filter to assignments for this principal (user ID, or email for users). + principal: Filter to assignments for this principal. Use a user ID or email address + for users; use a user-group ID with ``PrincipalType.USER_GROUP`` for user groups. principal_type: The kind of principal to list assignments for. Defaults to ``USER``. include_archived: If True, include archived assignments. filter_query: Explicit CEL query. @@ -412,8 +411,6 @@ async def unarchive_assignments( batch, principal_type=principal_type.value ) - # ───────── User resolution ───────── - async def resolve_user_id(self, email: str) -> str: """Resolve a user's email (its user name) to a user ID. diff --git a/python/lib/sift_client/resources/access_control/resource_attributes.py b/python/lib/sift_client/resources/access_control/resource_attributes.py index 360d089cd..86ba750a7 100644 --- a/python/lib/sift_client/resources/access_control/resource_attributes.py +++ b/python/lib/sift_client/resources/access_control/resource_attributes.py @@ -11,6 +11,7 @@ from sift_client.sift_types.resource_attribute import ( ResourceAttribute, ResourceAttributeEntity, + ResourceAttributeEntityType, ResourceAttributeEnumValue, ResourceAttributeKey, ResourceAttributeKeyType, @@ -23,26 +24,33 @@ from sift_client.client import SiftClient -# Max entities per BatchCreateResourceAttributes call; keeps request bodies well under -# gRPC message size limits when assigning to large entity sets. +# Max resources per BatchCreateResourceAttributes call; keeps request bodies well under +# gRPC message size limits when assigning to large resource sets. ASSIGN_BATCH_SIZE = 1000 -EntityLike = Union[ResourceAttributeEntity, Asset, Channel, Run] - - -def _resolve_entity(entity: EntityLike) -> ResourceAttributeEntity: - """Resolve an entity-like value to a ResourceAttributeEntity.""" - if isinstance(entity, ResourceAttributeEntity): - return entity - if isinstance(entity, Asset): - return ResourceAttributeEntity.for_asset(entity._id_or_error) - if isinstance(entity, Channel): - return ResourceAttributeEntity.for_channel(entity._id_or_error) - if isinstance(entity, Run): - return ResourceAttributeEntity.for_run(entity._id_or_error) +ResourceLike = Union[ResourceAttributeEntity, Asset, Channel, Run] +SUPPORTED_RESOURCE_ENTITY_TYPES = { + ResourceAttributeEntityType.ASSET, + ResourceAttributeEntityType.CHANNEL, + ResourceAttributeEntityType.RUN, +} + + +def _resolve_resource(resource: ResourceLike) -> ResourceAttributeEntity: + """Resolve a supported resource object to a ResourceAttributeEntity.""" + if isinstance(resource, ResourceAttributeEntity): + if resource.entity_type not in SUPPORTED_RESOURCE_ENTITY_TYPES: + raise ValueError("Resource attributes currently support assets, channels, and runs.") + return resource + if isinstance(resource, Asset): + return ResourceAttributeEntity.for_asset(resource._id_or_error) + if isinstance(resource, Channel): + return ResourceAttributeEntity.for_channel(resource._id_or_error) + if isinstance(resource, Run): + return ResourceAttributeEntity.for_run(resource._id_or_error) raise TypeError( - f"Cannot resolve entity of type {type(entity).__name__}. Pass a ResourceAttributeEntity, " - "Asset, Channel, or Run." + f"Cannot resolve resource of type {type(resource).__name__}. Pass a supported resource " + "object, such as an Asset, Channel, or Run, or pass a ResourceAttributeEntity." ) @@ -56,11 +64,14 @@ def _chunks(items: list[Any], size: int): class ResourceAttributesAPIAsync(ResourceBase): - """High-level API for resource attributes (ABAC). + """High-level API for resource attributes. + + Resource attributes describe the Sift objects an access decision applies to. A + resource is the "what" in an access decision. - Resource attributes assign attribute keys to Sift entities (assets, channels, runs). - The attribute key is the entry point: enum values and assignments are managed through - methods on a key, or through the corresponding methods here. + Create or fetch an attribute key, define enum values when the key uses them, then + assign a value to resources. For currently supported resource types, you can pass + existing ``Asset``, ``Channel``, and ``Run`` objects directly. """ def __init__(self, sift_client: SiftClient): @@ -74,8 +85,6 @@ def __init__(self, sift_client: SiftClient): grpc_client=self.client.grpc_client ) - # ───────── Keys ───────── - async def get_key(self, *, key_id: str) -> ResourceAttributeKey: """Get a resource attribute key by ID.""" key = await self._low_level_client.get_key(key_id) @@ -209,8 +218,6 @@ async def check_key_archive_impact(self, key: str | ResourceAttributeKey) -> int key_id = key._id_or_error if isinstance(key, ResourceAttributeKey) else key return await self._low_level_client.check_key_archive_impact(key_id) - # ───────── Enum values ───────── - async def create_enum_value( self, key: str | ResourceAttributeKey, @@ -300,22 +307,22 @@ async def unarchive_enum_value( value = await self._low_level_client.get_enum_value(enum_value_id) return self._apply_client_to_instance(value) - # ───────── Assignments ───────── - async def assign( self, key: ResourceAttributeKey, - entities: list[ResourceAttributeEntity | Asset | Channel | Run], + resources: list[ResourceAttributeEntity | Asset | Channel | Run], *, value: Any, ) -> list[ResourceAttribute]: - """Assign a value to entities for a key. + """Assign a key's value to resources. Args: key: The key to assign. Its ``key_type`` determines how ``value`` is interpreted. - entities: Entities to assign to (ResourceAttributeEntity, Asset, Channel, or Run). + resources: Resources to assign to. For currently supported resource types, pass + ``Asset``, ``Channel``, or ``Run`` objects directly, or use + ``ResourceAttributeEntity`` when you only have an ID. value: For ``SET_OF_ENUM``, a list of enum values (or their IDs) that becomes the - full set on each entity; for ``ENUM``, a single enum value; for ``BOOLEAN``, a + full set on each resource; for ``ENUM``, a single enum value; for ``BOOLEAN``, a bool; for ``NUMBER``, an int. Returns: @@ -323,7 +330,7 @@ async def assign( """ if not isinstance(key, ResourceAttributeKey): raise TypeError("assign requires a ResourceAttributeKey (with a known key_type).") - resolved = [_resolve_entity(e) for e in entities] + resolved = [_resolve_resource(resource) for resource in resources] create_kwargs = _resource_value_kwargs(key.key_type, value) created: list[ResourceAttribute] = [] @@ -343,7 +350,7 @@ async def list_assignments( self, *, key: str | ResourceAttributeKey | None = None, - entity: ResourceAttributeEntity | Asset | Channel | Run | None = None, + resource: ResourceAttributeEntity | Asset | Channel | Run | None = None, include_archived: bool = False, filter_query: str | None = None, order_by: str | None = None, @@ -354,15 +361,15 @@ async def list_assignments( Args: key: Filter to assignments of this key. - entity: Filter to assignments on this entity. When set, other filters are ignored. + resource: Filter to assignments on this resource. When set, other filters are ignored. include_archived: If True, include archived assignments. filter_query: Explicit CEL query. order_by: Field and direction to order by. limit: Maximum number of assignments to return. page_size: Results to fetch per request. """ - if entity is not None: - resolved = _resolve_entity(entity) + if resource is not None: + resolved = _resolve_resource(resource) attrs = await self._low_level_client.list_all_resource_attributes_by_entity( entity=resolved, include_archived=include_archived, diff --git a/python/lib/sift_client/resources/sync_stubs/__init__.pyi b/python/lib/sift_client/resources/sync_stubs/__init__.pyi index c37c3aed3..1da1d259e 100644 --- a/python/lib/sift_client/resources/sync_stubs/__init__.pyi +++ b/python/lib/sift_client/resources/sync_stubs/__init__.pyi @@ -1178,11 +1178,14 @@ class PingAPI: class PrincipalAttributesAPI: """Sync counterpart to `PrincipalAttributesAPIAsync`. - High-level API for principal attributes (ABAC). + High-level API for principal attributes. - Principal attributes assign attribute keys to principals (users or user groups). The - attribute key is the entry point: enum values and assignments are managed through - methods on a key, or through the corresponding methods here. + Principal attributes describe the users or groups an access decision applies to. + A principal is the "who" in an access decision, such as a user or user group. + + Create or fetch an attribute key, define enum values when the key uses them, then + assign a value to principals. User principals accept either user IDs or email + addresses; user-group principals use user-group IDs. """ def __init__(self, sift_client: SiftClient): @@ -1227,16 +1230,17 @@ class PrincipalAttributesAPI: value: Any, principal_type: PrincipalType = PrincipalType.USER, ) -> list[PrincipalAttributeValue]: - """Assign a value to principals for a key. + """Assign a key's value to principals. Args: key: The key to assign. Its ``value_type`` determines how ``value`` is interpreted. - principals: Principal IDs. For ``USER`` principals, an entry containing ``@`` is - treated as an email and resolved to a user ID. + principals: Principal IDs. For ``USER`` principals, entries containing ``@`` are + treated as email addresses and resolved to user IDs. value: For ``SET_OF_ENUM``, a list of enum values (or their IDs) that becomes the full set on each principal; for ``ENUM``, a single enum value; for ``BOOLEAN``, a bool; for ``NUMBER``, an int. - principal_type: The kind of principal being assigned to. Defaults to ``USER``. + principal_type: The kind of principal being assigned to. Defaults to ``USER``. Use + ``PrincipalType.USER_GROUP`` when assigning to user groups. Returns: The created assignments. @@ -1312,7 +1316,8 @@ class PrincipalAttributesAPI: Args: key: Filter to assignments of this key. - principal: Filter to assignments for this principal (user ID, or email for users). + principal: Filter to assignments for this principal. Use a user ID or email address + for users; use a user-group ID with ``PrincipalType.USER_GROUP`` for user groups. principal_type: The kind of principal to list assignments for. Defaults to ``USER``. include_archived: If True, include archived assignments. filter_query: Explicit CEL query. @@ -1675,11 +1680,14 @@ class ReportsAPI: class ResourceAttributesAPI: """Sync counterpart to `ResourceAttributesAPIAsync`. - High-level API for resource attributes (ABAC). + High-level API for resource attributes. + + Resource attributes describe the Sift objects an access decision applies to. A + resource is the "what" in an access decision. - Resource attributes assign attribute keys to Sift entities (assets, channels, runs). - The attribute key is the entry point: enum values and assignments are managed through - methods on a key, or through the corresponding methods here. + Create or fetch an attribute key, define enum values when the key uses them, then + assign a value to resources. For currently supported resource types, you can pass + existing ``Asset``, ``Channel``, and ``Run`` objects directly. """ def __init__(self, sift_client: SiftClient): @@ -1714,17 +1722,19 @@ class ResourceAttributesAPI: def assign( self, key: ResourceAttributeKey, - entities: list[ResourceAttributeEntity | Asset | Channel | Run], + resources: list[ResourceAttributeEntity | Asset | Channel | Run], *, value: Any, ) -> list[ResourceAttribute]: - """Assign a value to entities for a key. + """Assign a key's value to resources. Args: key: The key to assign. Its ``key_type`` determines how ``value`` is interpreted. - entities: Entities to assign to (ResourceAttributeEntity, Asset, Channel, or Run). + resources: Resources to assign to. For currently supported resource types, pass + ``Asset``, ``Channel``, or ``Run`` objects directly, or use + ``ResourceAttributeEntity`` when you only have an ID. value: For ``SET_OF_ENUM``, a list of enum values (or their IDs) that becomes the - full set on each entity; for ``ENUM``, a single enum value; for ``BOOLEAN``, a + full set on each resource; for ``ENUM``, a single enum value; for ``BOOLEAN``, a bool; for ``NUMBER``, an int. Returns: @@ -1793,7 +1803,7 @@ class ResourceAttributesAPI: self, *, key: str | ResourceAttributeKey | None = None, - entity: ResourceAttributeEntity | Asset | Channel | Run | None = None, + resource: ResourceAttributeEntity | Asset | Channel | Run | None = None, include_archived: bool = False, filter_query: str | None = None, order_by: str | None = None, @@ -1804,7 +1814,7 @@ class ResourceAttributesAPI: Args: key: Filter to assignments of this key. - entity: Filter to assignments on this entity. When set, other filters are ignored. + resource: Filter to assignments on this resource. When set, other filters are ignored. include_archived: If True, include archived assignments. filter_query: Explicit CEL query. order_by: Field and direction to order by. diff --git a/python/lib/sift_client/sift_types/principal_attribute.py b/python/lib/sift_client/sift_types/principal_attribute.py index 16204bdae..78e755226 100644 --- a/python/lib/sift_client/sift_types/principal_attribute.py +++ b/python/lib/sift_client/sift_types/principal_attribute.py @@ -1,7 +1,8 @@ -"""Domain types for principal attributes (ABAC). +"""Domain types for principal attributes. -Principal attributes assign attribute keys to principals (users or user groups) for -attribute based access control. The model mirrors resource attributes with three tiers: +Principal attributes describe the users or groups an access decision applies to. A +principal is the "who" in an access decision, such as a user or user group. The +model mirrors resource attributes with three tiers: - ``PrincipalAttributeKey`` defines an attribute and its value type. - ``PrincipalAttributeEnumValue`` is an allowed value for an ``ENUM``/``SET_OF_ENUM`` key. @@ -15,7 +16,7 @@ from datetime import datetime, timezone from enum import Enum -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Any from sift.principal_attributes.v1 import principal_attributes_pb2 as pa_pb @@ -264,18 +265,23 @@ def list_enum_values( ) def assign_to( - self, principals, *, value, principal_type: PrincipalType = PrincipalType.USER + self, + principals: list[str], + *, + value: Any, + principal_type: PrincipalType = PrincipalType.USER, ) -> list[PrincipalAttributeValue]: """Assign a value to one or more principals for this key. Args: - principals: Principal IDs to assign to. For ``USER`` principals, an entry - containing ``@`` is treated as an email and resolved to a user ID. + principals: Principal IDs to assign to. For ``USER`` principals, entries + containing ``@`` are treated as email addresses and resolved to user IDs. value: The value to assign. For ``SET_OF_ENUM`` keys, a list of enum values (or their IDs); for ``ENUM`` keys, a single enum value; for ``BOOLEAN`` keys, a bool; for ``NUMBER`` keys, an int. For ``SET_OF_ENUM`` this replaces the full set on each principal. - principal_type: The kind of principal being assigned to. Defaults to ``USER``. + principal_type: The kind of principal being assigned to. Defaults to ``USER``. Use + ``PrincipalType.USER_GROUP`` when assigning to user groups. Returns: The created assignments. diff --git a/python/lib/sift_client/sift_types/resource_attribute.py b/python/lib/sift_client/sift_types/resource_attribute.py index eb754cbc9..3f58403ae 100644 --- a/python/lib/sift_client/sift_types/resource_attribute.py +++ b/python/lib/sift_client/sift_types/resource_attribute.py @@ -1,11 +1,11 @@ -"""Domain types for resource attributes (ABAC). +"""Domain types for resource attributes. -Resource attributes assign attribute keys to Sift entities (assets, channels, runs) -for attribute based access control. The model has three tiers: +Resource attributes describe the Sift objects an access decision applies to. A resource +is the "what" in an access decision. The model has three tiers: - ``ResourceAttributeKey`` defines an attribute (e.g. ``licenses``) and its value type. - ``ResourceAttributeEnumValue`` is an allowed value for an ``ENUM``/``SET_OF_ENUM`` key. -- ``ResourceAttribute`` is a single assignment of a value to one entity. +- ``ResourceAttribute`` is a single assignment of a value to one resource. The ``ResourceAttributeKey`` acts as the entry point: enum values and assignments are managed through methods on a key instance. @@ -15,7 +15,7 @@ from datetime import datetime, timezone from enum import Enum -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Any from pydantic import BaseModel from sift.resource_attribute.v1 import resource_attribute_pb2 as ra_pb @@ -24,6 +24,9 @@ if TYPE_CHECKING: from sift_client.client import SiftClient + from sift_client.sift_types.asset import Asset + from sift_client.sift_types.channel import Channel + from sift_client.sift_types.run import Run class ResourceAttributeKeyType(Enum): @@ -37,7 +40,7 @@ class ResourceAttributeKeyType(Enum): class ResourceAttributeEntityType(Enum): - """Kind of Sift entity a resource attribute can be assigned to.""" + """Kind of Sift resource a resource attribute can be assigned to.""" UNSPECIFIED = ra_pb.RESOURCE_ATTRIBUTE_ENTITY_TYPE_UNSPECIFIED ASSET = ra_pb.RESOURCE_ATTRIBUTE_ENTITY_TYPE_ASSET @@ -46,24 +49,24 @@ class ResourceAttributeEntityType(Enum): class ResourceAttributeEntity(BaseModel): - """Identifies the entity a resource attribute is assigned to.""" + """Identifies the supported resource a resource attribute is assigned to.""" entity_id: str entity_type: ResourceAttributeEntityType @classmethod def for_asset(cls, entity_id: str) -> ResourceAttributeEntity: - """Build an entity identifier for an asset ID.""" + """Build an identifier for an asset ID.""" return cls(entity_id=entity_id, entity_type=ResourceAttributeEntityType.ASSET) @classmethod def for_channel(cls, entity_id: str) -> ResourceAttributeEntity: - """Build an entity identifier for a channel ID.""" + """Build an identifier for a channel ID.""" return cls(entity_id=entity_id, entity_type=ResourceAttributeEntityType.CHANNEL) @classmethod def for_run(cls, entity_id: str) -> ResourceAttributeEntity: - """Build an entity identifier for a run ID.""" + """Build an identifier for a run ID.""" return cls(entity_id=entity_id, entity_type=ResourceAttributeEntityType.RUN) @classmethod @@ -147,7 +150,7 @@ def __str__(self) -> str: class ResourceAttribute(BaseType[ra_pb.ResourceAttribute, "ResourceAttribute"]): - """A single assignment of a resource attribute value to an entity.""" + """A single assignment of a resource attribute value to a supported resource.""" organization_id: str key_id: str @@ -296,21 +299,27 @@ def list_enum_values( self, include_archived=include_archived ) - def assign_to(self, entities, *, value) -> list[ResourceAttribute]: - """Assign a value to one or more entities for this key. + def assign_to( + self, + resources: list[ResourceAttributeEntity | Asset | Channel | Run], + *, + value: Any, + ) -> list[ResourceAttribute]: + """Assign a value to one or more resources for this key. Args: - entities: Entities to assign to. Each may be a ``ResourceAttributeEntity`` - or an ``Asset``/``Channel``/``Run`` instance. + resources: Resources to assign to. For currently supported resource types, pass + ``Asset``, ``Channel``, or ``Run`` objects directly, or use + ``ResourceAttributeEntity`` when you only have an ID. value: The value to assign. For ``SET_OF_ENUM`` keys, a list of enum values (or their IDs); for ``ENUM`` keys, a single enum value; for ``BOOLEAN`` keys, a bool; for ``NUMBER`` keys, an int. For ``SET_OF_ENUM`` this - replaces the full set on each entity. + replaces the full set on each resource. Returns: The created assignments. """ - return self.client.access_control.resource_attributes.assign(self, entities, value=value) + return self.client.access_control.resource_attributes.assign(self, resources, value=value) def list_assignments(self, *, include_archived: bool = False) -> list[ResourceAttribute]: """List all assignments of this key.""" diff --git a/python/lib/sift_client/util/util.py b/python/lib/sift_client/util/util.py index 35c17f748..5474c462c 100644 --- a/python/lib/sift_client/util/util.py +++ b/python/lib/sift_client/util/util.py @@ -56,7 +56,7 @@ class AsyncAPIs(NamedTuple): """Instance of the Rules API for making asynchronous requests.""" access_control: AccessControlAPIAsync - """Namespace for access-control APIs.""" + """Async access-control APIs for configuring who can access what in Sift.""" tags: TagsAPIAsync """Instance of the Tags API for making asynchronous requests.""" From 1e7229614c71ae5ac8c5093f668cda7e299c9960 Mon Sep 17 00:00:00 2001 From: Wei Qi Lu Date: Tue, 30 Jun 2026 14:40:57 -0700 Subject: [PATCH 06/21] support ID-based assignments --- python/CHANGELOG.md | 4 +- .../resources/test_principal_attributes.py | 14 ++ .../resources/test_resource_attributes.py | 55 ++++++++ .../sift_types/test_resource_attribute.py | 6 +- .../access_control/principal_attributes.py | 20 ++- .../access_control/resource_attributes.py | 120 +++++++++++++++--- .../resources/sync_stubs/__init__.pyi | 19 +-- .../sift_types/resource_attribute.py | 6 +- 8 files changed, 204 insertions(+), 40 deletions(-) diff --git a/python/CHANGELOG.md b/python/CHANGELOG.md index afa240f25..149aea385 100644 --- a/python/CHANGELOG.md +++ b/python/CHANGELOG.md @@ -11,7 +11,7 @@ This project adheres to [Semantic Versioning](http://semver.org/). Added a public API for attribute-based access control (ABAC) attributes under `client.access_control`. Resource attributes describe the Sift objects an access decision applies to, such as assets, channels, and runs. Principal attributes describe the users or groups an access decision applies to. Async APIs are available under `client.async_.access_control`. -An attribute key is the entry point. Create or fetch a key, define its enum values, then assign a value to a set of resources: +An attribute key is the entry point. Create or fetch a key, define its enum values, then assign a value to a set of resources. Resource assignments accept supported resource objects or IDs: ```python from sift_client.sift_types import ResourceAttributeKeyType @@ -21,7 +21,7 @@ key = client.access_control.resource_attributes.get_or_create_key( ResourceAttributeKeyType.SET_OF_ENUM, ) licenses = key.get_or_create_enum_values(["LICENSE_A", "LICENSE_B"]) -key.assign_to(channels, value=licenses) +key.assign_to(["channel-id"], value=licenses) ``` Principal attributes accept user IDs or email addresses, resolving emails to user IDs automatically: diff --git a/python/lib/sift_client/_tests/resources/test_principal_attributes.py b/python/lib/sift_client/_tests/resources/test_principal_attributes.py index c08788bdf..8938da3b9 100644 --- a/python/lib/sift_client/_tests/resources/test_principal_attributes.py +++ b/python/lib/sift_client/_tests/resources/test_principal_attributes.py @@ -66,6 +66,20 @@ async def test_resolve_user_id_raises_when_missing(self): class TestAssign: + @pytest.mark.asyncio + async def test_fetches_key_when_assigning_by_key_id(self): + api = _api() + api._low_level_client.get_key = AsyncMock(return_value=_key()) + api._low_level_client.batch_create_values = AsyncMock(return_value=[]) + + await api.assign("pk1", ["u1"], value=["e_a"]) + + api._low_level_client.get_key.assert_awaited_once_with("pk1") + kwargs = api._low_level_client.batch_create_values.call_args.kwargs + assert kwargs["key_id"] == "pk1" + assert kwargs["principal_ids"] == ["u1"] + assert kwargs["enum_value_ids"] == ["e_a"] + @pytest.mark.asyncio async def test_resolves_emails_and_keeps_raw_ids(self): api = _api() diff --git a/python/lib/sift_client/_tests/resources/test_resource_attributes.py b/python/lib/sift_client/_tests/resources/test_resource_attributes.py index 8d5640d10..71021a8e7 100644 --- a/python/lib/sift_client/_tests/resources/test_resource_attributes.py +++ b/python/lib/sift_client/_tests/resources/test_resource_attributes.py @@ -5,6 +5,7 @@ integration tests and run without a backend. """ +from types import SimpleNamespace from unittest.mock import AsyncMock, MagicMock import pytest @@ -43,6 +44,10 @@ def _enum(eid: str, name: str) -> ResourceAttributeEnumValue: ) +def _matched_resource(resource_id: str): + return SimpleNamespace(_id_or_error=resource_id) + + class TestGetOrCreateKey: @pytest.mark.asyncio async def test_returns_existing_without_creating(self): @@ -129,6 +134,56 @@ async def test_resolves_domain_objects_to_entities(self): assert entities[0].entity_type == ResourceAttributeEntity.for_asset("a1").entity_type assert entities[0].entity_id == "a1" + @pytest.mark.asyncio + async def test_fetches_key_when_assigning_by_key_id(self): + api = _api() + api._low_level_client.get_key = AsyncMock(return_value=_key()) + api._low_level_client.batch_create_resource_attributes = AsyncMock(return_value=[]) + + await api.assign("k1", [ResourceAttributeEntity.for_channel("ch1")], value=["e_a"]) + + api._low_level_client.get_key.assert_awaited_once_with("k1") + kwargs = api._low_level_client.batch_create_resource_attributes.call_args.kwargs + assert kwargs["key_id"] == "k1" + assert kwargs["enum_value_ids"] == ["e_a"] + + @pytest.mark.asyncio + async def test_resolves_resource_ids_to_supported_entity_type(self): + api = _api() + api._low_level_client.batch_create_resource_attributes = AsyncMock(return_value=[]) + api.client.async_.assets.list_ = AsyncMock(return_value=[]) + api.client.async_.channels.list_ = AsyncMock(return_value=[_matched_resource("ch1")]) + api.client.async_.runs.list_ = AsyncMock(return_value=[]) + + await api.assign(_key(), ["ch1", "ch1"], value=["e_a"]) + + api.client.async_.assets.list_.assert_awaited_once_with( + asset_ids=["ch1"], include_archived=True + ) + api.client.async_.channels.list_.assert_awaited_once_with(channel_ids=["ch1"]) + api.client.async_.runs.list_.assert_awaited_once_with( + run_ids=["ch1"], include_archived=True + ) + kwargs = api._low_level_client.batch_create_resource_attributes.call_args.kwargs + entities = kwargs["entities"] + assert entities[0].entity_type == ResourceAttributeEntityType.CHANNEL + assert entities[0].entity_id == "ch1" + assert entities[1].entity_type == ResourceAttributeEntityType.CHANNEL + assert entities[1].entity_id == "ch1" + + @pytest.mark.asyncio + async def test_rejects_unknown_resource_ids(self): + api = _api() + api._low_level_client.batch_create_resource_attributes = AsyncMock(return_value=[]) + api.client.async_.assets.list_ = AsyncMock(return_value=[]) + api.client.async_.channels.list_ = AsyncMock(return_value=[]) + api.client.async_.runs.list_ = AsyncMock(return_value=[]) + + with pytest.raises(ValueError, match="Could not resolve resource ID"): + await api.assign(_key(), ["missing"], value=["e_a"]) + + api._low_level_client.batch_create_resource_attributes.assert_not_called() + @pytest.mark.asyncio async def test_rejects_resource_entity_types_outside_current_supported_targets(self): api = _api() diff --git a/python/lib/sift_client/_tests/sift_types/test_resource_attribute.py b/python/lib/sift_client/_tests/sift_types/test_resource_attribute.py index db07bf964..5a7470816 100644 --- a/python/lib/sift_client/_tests/sift_types/test_resource_attribute.py +++ b/python/lib/sift_client/_tests/sift_types/test_resource_attribute.py @@ -149,12 +149,10 @@ def test_assign_to_delegates(self, mock_client): key._apply_client_to_instance(mock_client) mock_client.access_control.resource_attributes.assign.return_value = ["sentinel"] - resource = ResourceAttributeEntity.for_channel("ch1") - - result = key.assign_to([resource], value=["LIC_A"]) + result = key.assign_to(["ch1"], value=["LIC_A"]) mock_client.access_control.resource_attributes.assign.assert_called_once_with( - key, [resource], value=["LIC_A"] + key, ["ch1"], value=["LIC_A"] ) assert result == ["sentinel"] diff --git a/python/lib/sift_client/resources/access_control/principal_attributes.py b/python/lib/sift_client/resources/access_control/principal_attributes.py index 9b52e8e0a..c225e3c46 100644 --- a/python/lib/sift_client/resources/access_control/principal_attributes.py +++ b/python/lib/sift_client/resources/access_control/principal_attributes.py @@ -66,6 +66,15 @@ def __init__(self, sift_client: SiftClient): grpc_client=self.client.grpc_client ) + async def _resolve_key(self, key: str | PrincipalAttributeKey) -> PrincipalAttributeKey: + if isinstance(key, PrincipalAttributeKey): + return key + if not isinstance(key, str): + raise TypeError("assign requires a PrincipalAttributeKey or key ID string.") + if not key: + raise ValueError("Key ID cannot be empty.") + return await self.get_key(key_id=key) + async def get_key(self, *, key_id: str) -> PrincipalAttributeKey: """Get a principal attribute key by ID.""" key = await self._low_level_client.get_key(key_id) @@ -281,7 +290,7 @@ async def unarchive_enum_value( async def assign( self, - key: PrincipalAttributeKey, + key: str | PrincipalAttributeKey, principals: list[str], *, value: Any, @@ -290,7 +299,7 @@ async def assign( """Assign a key's value to principals. Args: - key: The key to assign. Its ``value_type`` determines how ``value`` is interpreted. + key: The key or key ID to assign. Its ``value_type`` determines how ``value`` is interpreted. principals: Principal IDs. For ``USER`` principals, entries containing ``@`` are treated as email addresses and resolved to user IDs. value: For ``SET_OF_ENUM``, a list of enum values (or their IDs) that becomes the @@ -302,15 +311,14 @@ async def assign( Returns: The created assignments. """ - if not isinstance(key, PrincipalAttributeKey): - raise TypeError("assign requires a PrincipalAttributeKey (with a known value_type).") + resolved_key = await self._resolve_key(key) resolved_ids = await self._resolve_principal_ids(principals, principal_type=principal_type) - create_kwargs = _principal_value_kwargs(key.value_type, value) + create_kwargs = _principal_value_kwargs(resolved_key.value_type, value) created: list[PrincipalAttributeValue] = [] for batch in _chunks(resolved_ids, ASSIGN_BATCH_SIZE): values = await self._low_level_client.batch_create_values( - key_id=key._id_or_error, + key_id=resolved_key._id_or_error, principal_ids=batch, principal_type=principal_type.value, **create_kwargs, diff --git a/python/lib/sift_client/resources/access_control/resource_attributes.py b/python/lib/sift_client/resources/access_control/resource_attributes.py index 86ba750a7..5a7fb1890 100644 --- a/python/lib/sift_client/resources/access_control/resource_attributes.py +++ b/python/lib/sift_client/resources/access_control/resource_attributes.py @@ -1,5 +1,6 @@ from __future__ import annotations +import asyncio from typing import TYPE_CHECKING, Any, Union from sift_client._internal.low_level_wrappers.resource_attributes import ( @@ -28,7 +29,7 @@ # gRPC message size limits when assigning to large resource sets. ASSIGN_BATCH_SIZE = 1000 -ResourceLike = Union[ResourceAttributeEntity, Asset, Channel, Run] +ResourceLike = Union[ResourceAttributeEntity, Asset, Channel, Run, str] SUPPORTED_RESOURCE_ENTITY_TYPES = { ResourceAttributeEntityType.ASSET, ResourceAttributeEntityType.CHANNEL, @@ -36,7 +37,7 @@ } -def _resolve_resource(resource: ResourceLike) -> ResourceAttributeEntity: +def _resolve_resource_object(resource: ResourceLike) -> ResourceAttributeEntity: """Resolve a supported resource object to a ResourceAttributeEntity.""" if isinstance(resource, ResourceAttributeEntity): if resource.entity_type not in SUPPORTED_RESOURCE_ENTITY_TYPES: @@ -50,7 +51,8 @@ def _resolve_resource(resource: ResourceLike) -> ResourceAttributeEntity: return ResourceAttributeEntity.for_run(resource._id_or_error) raise TypeError( f"Cannot resolve resource of type {type(resource).__name__}. Pass a supported resource " - "object, such as an Asset, Channel, or Run, or pass a ResourceAttributeEntity." + "object, such as an Asset, Channel, or Run; a resource ID string; or a " + "ResourceAttributeEntity." ) @@ -71,7 +73,7 @@ class ResourceAttributesAPIAsync(ResourceBase): Create or fetch an attribute key, define enum values when the key uses them, then assign a value to resources. For currently supported resource types, you can pass - existing ``Asset``, ``Channel``, and ``Run`` objects directly. + existing ``Asset``, ``Channel``, and ``Run`` objects or their IDs directly. """ def __init__(self, sift_client: SiftClient): @@ -85,6 +87,92 @@ def __init__(self, sift_client: SiftClient): grpc_client=self.client.grpc_client ) + async def _resolve_key(self, key: str | ResourceAttributeKey) -> ResourceAttributeKey: + if isinstance(key, ResourceAttributeKey): + return key + if not isinstance(key, str): + raise TypeError("assign requires a ResourceAttributeKey or key ID string.") + if not key: + raise ValueError("Key ID cannot be empty.") + return await self.get_key(key_id=key) + + async def _resolve_resource(self, resource: ResourceLike) -> ResourceAttributeEntity: + return (await self._resolve_resources([resource]))[0] + + async def _resolve_resources( + self, resources: list[ResourceAttributeEntity | Asset | Channel | Run | str] + ) -> list[ResourceAttributeEntity]: + resolved: list[ResourceAttributeEntity | str] = [] + resource_ids: list[str] = [] + for resource in resources: + if isinstance(resource, str): + if not resource: + raise ValueError("Resource ID cannot be empty.") + resolved.append(resource) + resource_ids.append(resource) + else: + resolved.append(_resolve_resource_object(resource)) + + if not resource_ids: + return [ + resource for resource in resolved if isinstance(resource, ResourceAttributeEntity) + ] + + resources_by_id = await self._resolve_resource_ids(resource_ids) + return [ + resources_by_id[resource] if isinstance(resource, str) else resource + for resource in resolved + ] + + async def _resolve_resource_ids( + self, resource_ids: list[str] + ) -> dict[str, ResourceAttributeEntity]: + unique_ids = list(dict.fromkeys(resource_ids)) + matched_types: dict[str, set[ResourceAttributeEntityType]] = {} + for batch in _chunks(unique_ids, ASSIGN_BATCH_SIZE): + assets, channels, runs = await asyncio.gather( + self.client.async_.assets.list_(asset_ids=batch, include_archived=True), + self.client.async_.channels.list_(channel_ids=batch), + self.client.async_.runs.list_(run_ids=batch, include_archived=True), + ) + for resources_for_type, entity_type in ( + (assets, ResourceAttributeEntityType.ASSET), + (channels, ResourceAttributeEntityType.CHANNEL), + (runs, ResourceAttributeEntityType.RUN), + ): + for resource in resources_for_type: + matched_types.setdefault(resource._id_or_error, set()).add(entity_type) + + ambiguous_ids = [ + resource_id + for resource_id, entity_types in matched_types.items() + if len(entity_types) > 1 + ] + if ambiguous_ids: + raise ValueError( + f"Resource ID {ambiguous_ids[0]!r} matched multiple currently supported " + "resource types." + ) + + missing_ids = [ + resource_id for resource_id in unique_ids if resource_id not in matched_types + ] + if missing_ids: + preview = ", ".join(repr(resource_id) for resource_id in missing_ids[:3]) + if len(missing_ids) > 3: + preview = f"{preview}, and {len(missing_ids) - 3} more" + raise ValueError( + f"Could not resolve resource ID(s) {preview} as currently supported resources " + "(asset, channel, or run)." + ) + + return { + resource_id: ResourceAttributeEntity( + entity_id=resource_id, entity_type=next(iter(entity_types)) + ) + for resource_id, entity_types in matched_types.items() + } + async def get_key(self, *, key_id: str) -> ResourceAttributeKey: """Get a resource attribute key by ID.""" key = await self._low_level_client.get_key(key_id) @@ -309,18 +397,18 @@ async def unarchive_enum_value( async def assign( self, - key: ResourceAttributeKey, - resources: list[ResourceAttributeEntity | Asset | Channel | Run], + key: str | ResourceAttributeKey, + resources: list[ResourceAttributeEntity | Asset | Channel | Run | str], *, value: Any, ) -> list[ResourceAttribute]: """Assign a key's value to resources. Args: - key: The key to assign. Its ``key_type`` determines how ``value`` is interpreted. + key: The key or key ID to assign. Its ``key_type`` determines how ``value`` is interpreted. resources: Resources to assign to. For currently supported resource types, pass - ``Asset``, ``Channel``, or ``Run`` objects directly, or use - ``ResourceAttributeEntity`` when you only have an ID. + ``Asset``, ``Channel``, or ``Run`` objects, their IDs, or + ``ResourceAttributeEntity`` when you already know the resource type. value: For ``SET_OF_ENUM``, a list of enum values (or their IDs) that becomes the full set on each resource; for ``ENUM``, a single enum value; for ``BOOLEAN``, a bool; for ``NUMBER``, an int. @@ -328,15 +416,14 @@ async def assign( Returns: The created assignments. """ - if not isinstance(key, ResourceAttributeKey): - raise TypeError("assign requires a ResourceAttributeKey (with a known key_type).") - resolved = [_resolve_resource(resource) for resource in resources] - create_kwargs = _resource_value_kwargs(key.key_type, value) + resolved_key = await self._resolve_key(key) + resolved = await self._resolve_resources(resources) + create_kwargs = _resource_value_kwargs(resolved_key.key_type, value) created: list[ResourceAttribute] = [] for batch in _chunks(resolved, ASSIGN_BATCH_SIZE): attrs = await self._low_level_client.batch_create_resource_attributes( - key_id=key._id_or_error, entities=batch, **create_kwargs + key_id=resolved_key._id_or_error, entities=batch, **create_kwargs ) created.extend(attrs) return self._apply_client_to_instances(created) @@ -350,7 +437,7 @@ async def list_assignments( self, *, key: str | ResourceAttributeKey | None = None, - resource: ResourceAttributeEntity | Asset | Channel | Run | None = None, + resource: ResourceAttributeEntity | Asset | Channel | Run | str | None = None, include_archived: bool = False, filter_query: str | None = None, order_by: str | None = None, @@ -362,6 +449,7 @@ async def list_assignments( Args: key: Filter to assignments of this key. resource: Filter to assignments on this resource. When set, other filters are ignored. + Pass a resource object, resource ID, or ``ResourceAttributeEntity``. include_archived: If True, include archived assignments. filter_query: Explicit CEL query. order_by: Field and direction to order by. @@ -369,7 +457,7 @@ async def list_assignments( page_size: Results to fetch per request. """ if resource is not None: - resolved = _resolve_resource(resource) + resolved = await self._resolve_resource(resource) attrs = await self._low_level_client.list_all_resource_attributes_by_entity( entity=resolved, include_archived=include_archived, diff --git a/python/lib/sift_client/resources/sync_stubs/__init__.pyi b/python/lib/sift_client/resources/sync_stubs/__init__.pyi index 1da1d259e..f60acbcb3 100644 --- a/python/lib/sift_client/resources/sync_stubs/__init__.pyi +++ b/python/lib/sift_client/resources/sync_stubs/__init__.pyi @@ -1224,7 +1224,7 @@ class PrincipalAttributesAPI: def assign( self, - key: PrincipalAttributeKey, + key: str | PrincipalAttributeKey, principals: list[str], *, value: Any, @@ -1233,7 +1233,7 @@ class PrincipalAttributesAPI: """Assign a key's value to principals. Args: - key: The key to assign. Its ``value_type`` determines how ``value`` is interpreted. + key: The key or key ID to assign. Its ``value_type`` determines how ``value`` is interpreted. principals: Principal IDs. For ``USER`` principals, entries containing ``@`` are treated as email addresses and resolved to user IDs. value: For ``SET_OF_ENUM``, a list of enum values (or their IDs) that becomes the @@ -1687,7 +1687,7 @@ class ResourceAttributesAPI: Create or fetch an attribute key, define enum values when the key uses them, then assign a value to resources. For currently supported resource types, you can pass - existing ``Asset``, ``Channel``, and ``Run`` objects directly. + existing ``Asset``, ``Channel``, and ``Run`` objects or their IDs directly. """ def __init__(self, sift_client: SiftClient): @@ -1721,18 +1721,18 @@ class ResourceAttributesAPI: def assign( self, - key: ResourceAttributeKey, - resources: list[ResourceAttributeEntity | Asset | Channel | Run], + key: str | ResourceAttributeKey, + resources: list[ResourceAttributeEntity | Asset | Channel | Run | str], *, value: Any, ) -> list[ResourceAttribute]: """Assign a key's value to resources. Args: - key: The key to assign. Its ``key_type`` determines how ``value`` is interpreted. + key: The key or key ID to assign. Its ``key_type`` determines how ``value`` is interpreted. resources: Resources to assign to. For currently supported resource types, pass - ``Asset``, ``Channel``, or ``Run`` objects directly, or use - ``ResourceAttributeEntity`` when you only have an ID. + ``Asset``, ``Channel``, or ``Run`` objects, their IDs, or + ``ResourceAttributeEntity`` when you already know the resource type. value: For ``SET_OF_ENUM``, a list of enum values (or their IDs) that becomes the full set on each resource; for ``ENUM``, a single enum value; for ``BOOLEAN``, a bool; for ``NUMBER``, an int. @@ -1803,7 +1803,7 @@ class ResourceAttributesAPI: self, *, key: str | ResourceAttributeKey | None = None, - resource: ResourceAttributeEntity | Asset | Channel | Run | None = None, + resource: ResourceAttributeEntity | Asset | Channel | Run | str | None = None, include_archived: bool = False, filter_query: str | None = None, order_by: str | None = None, @@ -1815,6 +1815,7 @@ class ResourceAttributesAPI: Args: key: Filter to assignments of this key. resource: Filter to assignments on this resource. When set, other filters are ignored. + Pass a resource object, resource ID, or ``ResourceAttributeEntity``. include_archived: If True, include archived assignments. filter_query: Explicit CEL query. order_by: Field and direction to order by. diff --git a/python/lib/sift_client/sift_types/resource_attribute.py b/python/lib/sift_client/sift_types/resource_attribute.py index 3f58403ae..a5e8cebb4 100644 --- a/python/lib/sift_client/sift_types/resource_attribute.py +++ b/python/lib/sift_client/sift_types/resource_attribute.py @@ -301,7 +301,7 @@ def list_enum_values( def assign_to( self, - resources: list[ResourceAttributeEntity | Asset | Channel | Run], + resources: list[ResourceAttributeEntity | Asset | Channel | Run | str], *, value: Any, ) -> list[ResourceAttribute]: @@ -309,8 +309,8 @@ def assign_to( Args: resources: Resources to assign to. For currently supported resource types, pass - ``Asset``, ``Channel``, or ``Run`` objects directly, or use - ``ResourceAttributeEntity`` when you only have an ID. + ``Asset``, ``Channel``, or ``Run`` objects, their IDs, or + ``ResourceAttributeEntity`` when you already know the resource type. value: The value to assign. For ``SET_OF_ENUM`` keys, a list of enum values (or their IDs); for ``ENUM`` keys, a single enum value; for ``BOOLEAN`` keys, a bool; for ``NUMBER`` keys, an int. For ``SET_OF_ENUM`` this From 447e36350f88c2ce0e66502226f8dcd733c9248e Mon Sep 17 00:00:00 2001 From: Wei Qi Lu Date: Tue, 30 Jun 2026 15:00:44 -0700 Subject: [PATCH 07/21] mypy --- .../access_control/resource_attributes.py | 22 +++++++++++++------ 1 file changed, 15 insertions(+), 7 deletions(-) diff --git a/python/lib/sift_client/resources/access_control/resource_attributes.py b/python/lib/sift_client/resources/access_control/resource_attributes.py index 5a7fb1890..b923f8442 100644 --- a/python/lib/sift_client/resources/access_control/resource_attributes.py +++ b/python/lib/sift_client/resources/access_control/resource_attributes.py @@ -130,18 +130,26 @@ async def _resolve_resource_ids( unique_ids = list(dict.fromkeys(resource_ids)) matched_types: dict[str, set[ResourceAttributeEntityType]] = {} for batch in _chunks(unique_ids, ASSIGN_BATCH_SIZE): + assets: list[Asset] + channels: list[Channel] + runs: list[Run] assets, channels, runs = await asyncio.gather( self.client.async_.assets.list_(asset_ids=batch, include_archived=True), self.client.async_.channels.list_(channel_ids=batch), self.client.async_.runs.list_(run_ids=batch, include_archived=True), ) - for resources_for_type, entity_type in ( - (assets, ResourceAttributeEntityType.ASSET), - (channels, ResourceAttributeEntityType.CHANNEL), - (runs, ResourceAttributeEntityType.RUN), - ): - for resource in resources_for_type: - matched_types.setdefault(resource._id_or_error, set()).add(entity_type) + for asset in assets: + matched_types.setdefault(asset._id_or_error, set()).add( + ResourceAttributeEntityType.ASSET + ) + for channel in channels: + matched_types.setdefault(channel._id_or_error, set()).add( + ResourceAttributeEntityType.CHANNEL + ) + for run in runs: + matched_types.setdefault(run._id_or_error, set()).add( + ResourceAttributeEntityType.RUN + ) ambiguous_ids = [ resource_id From 6cab6ebb0f95a399eb6013877788a85b3c5eceb0 Mon Sep 17 00:00:00 2001 From: Wei Qi Lu Date: Thu, 2 Jul 2026 15:52:01 -0700 Subject: [PATCH 08/21] align access-control types and internals, add typed key updates and client.users resource --- python/CHANGELOG.md | 14 +- .../_internal/low_level_wrappers/__init__.py | 2 + .../principal_attributes.py | 94 ++++---- .../low_level_wrappers/resource_attributes.py | 94 ++++---- .../_internal/low_level_wrappers/users.py | 130 ++++++++++ python/lib/sift_client/_internal/util/util.py | 13 +- .../test_principal_attributes.py | 66 +++++ .../test_resource_attributes.py | 69 +++++- .../low_level_wrappers/test_users.py | 85 +++++++ .../sift_client/_tests/resources/test_base.py | 35 +++ .../resources/test_principal_attributes.py | 59 ++--- .../resources/test_resource_attributes.py | 36 ++- .../_tests/resources/test_users.py | 123 ++++++++++ .../sift_types/test_principal_attribute.py | 77 +++++- .../sift_types/test_resource_attribute.py | 74 +++++- .../_tests/sift_types/test_user.py | 47 ++++ .../sift_client/_tests/util/test_cel_utils.py | 17 ++ python/lib/sift_client/client.py | 7 + python/lib/sift_client/resources/__init__.py | 4 + python/lib/sift_client/resources/_base.py | 11 +- .../resources/access_control/_common.py | 62 +++++ .../access_control/principal_attributes.py | 228 +++++------------- .../access_control/resource_attributes.py | 172 ++++++------- .../sift_client/resources/file_attachments.py | 27 +-- .../resources/sync_stubs/__init__.py | 3 + .../resources/sync_stubs/__init__.pyi | 169 +++++++++---- python/lib/sift_client/resources/users.py | 125 ++++++++++ python/lib/sift_client/sift_types/__init__.py | 23 +- .../sift_types/principal_attribute.py | 72 ++++-- .../sift_types/resource_attribute.py | 78 ++++-- python/lib/sift_client/sift_types/user.py | 57 +++++ python/lib/sift_client/util/cel_utils.py | 18 +- python/lib/sift_client/util/util.py | 4 + 33 files changed, 1534 insertions(+), 561 deletions(-) create mode 100644 python/lib/sift_client/_internal/low_level_wrappers/users.py create mode 100644 python/lib/sift_client/_tests/_internal/low_level_wrappers/test_users.py create mode 100644 python/lib/sift_client/_tests/resources/test_base.py create mode 100644 python/lib/sift_client/_tests/resources/test_users.py create mode 100644 python/lib/sift_client/_tests/sift_types/test_user.py create mode 100644 python/lib/sift_client/resources/access_control/_common.py create mode 100644 python/lib/sift_client/resources/users.py create mode 100644 python/lib/sift_client/sift_types/user.py diff --git a/python/CHANGELOG.md b/python/CHANGELOG.md index bca51f154..bd8a54afb 100644 --- a/python/CHANGELOG.md +++ b/python/CHANGELOG.md @@ -68,11 +68,11 @@ Added a public API for attribute-based access control (ABAC) attributes under `c An attribute key is the entry point. Create or fetch a key, define its enum values, then assign a value to a set of resources. Resource assignments accept supported resource objects or IDs: ```python -from sift_client.sift_types import ResourceAttributeKeyType +from sift_client.sift_types import ResourceAttributeValueType key = client.access_control.resource_attributes.get_or_create_key( "licenses", - ResourceAttributeKeyType.SET_OF_ENUM, + ResourceAttributeValueType.SET_OF_ENUM, ) licenses = key.get_or_create_enum_values(["LICENSE_A", "LICENSE_B"]) key.assign_to(["channel-id"], value=licenses) @@ -93,6 +93,16 @@ key.assign_to(["user@example.com"], value=licenses) Keys, enum values, and assignments each support create, get, list, update, and archive operations. For `SET_OF_ENUM` keys, an assignment replaces the full value set on each target. +#### Users + +Added a users API at `client.users` (async: `client.async_.users`). A user's `name` is their login name, typically their email address: + +```python +user = client.users.find(name="alice@example.com") +users = client.users.list_(name_contains="@example.com") +ids = client.users.resolve_ids(["alice@example.com", "bob@example.com"]) +``` + ## [v0.18.0] - June 19, 2026 This release is a major overhaul of the pytest plugin. One import enables it, with a hierarchical report tree, offline and disabled run modes, pass/fail and abort handling that rolls up correctly, a terminal report summary, project-level configuration, and an audit log for diagnosing field failures. diff --git a/python/lib/sift_client/_internal/low_level_wrappers/__init__.py b/python/lib/sift_client/_internal/low_level_wrappers/__init__.py index 29bb69fcc..5fa622da5 100644 --- a/python/lib/sift_client/_internal/low_level_wrappers/__init__.py +++ b/python/lib/sift_client/_internal/low_level_wrappers/__init__.py @@ -23,6 +23,7 @@ from sift_client._internal.low_level_wrappers.test_results import TestResultsLowLevelClient from sift_client._internal.low_level_wrappers.units import UnitsLowLevelClient from sift_client._internal.low_level_wrappers.upload import UploadLowLevelClient +from sift_client._internal.low_level_wrappers.users import UsersLowLevelClient __all__ = [ "AssetsLowLevelClient", @@ -42,4 +43,5 @@ "TestResultsLowLevelClient", "UnitsLowLevelClient", "UploadLowLevelClient", + "UsersLowLevelClient", ] diff --git a/python/lib/sift_client/_internal/low_level_wrappers/principal_attributes.py b/python/lib/sift_client/_internal/low_level_wrappers/principal_attributes.py index 65a576f9d..59e429475 100644 --- a/python/lib/sift_client/_internal/low_level_wrappers/principal_attributes.py +++ b/python/lib/sift_client/_internal/low_level_wrappers/principal_attributes.py @@ -39,16 +39,17 @@ UnarchivePrincipalAttributeValuesRequest, UpdatePrincipalAttributeEnumValueRequest, UpdatePrincipalAttributeEnumValueResponse, - UpdatePrincipalAttributeKeyRequest, UpdatePrincipalAttributeKeyResponse, ) from sift.principal_attributes.v1.principal_attributes_pb2_grpc import PrincipalAttributeServiceStub from sift_client._internal.low_level_wrappers.base import LowLevelClientBase +from sift_client._internal.util.util import chunked from sift_client.sift_types.principal_attribute import ( + PrincipalAttributeAssignment, PrincipalAttributeEnumValue, PrincipalAttributeKey, - PrincipalAttributeValue, + PrincipalAttributeKeyUpdate, ) from sift_client.transport import WithGrpcClient @@ -57,6 +58,10 @@ logger = logging.getLogger(__name__) +# Max principal/value IDs per batch RPC; keeps request bodies well under gRPC +# message size limits when operating on large principal sets. +_BATCH_SIZE = 1000 + class PrincipalAttributesLowLevelClient(LowLevelClientBase, WithGrpcClient): """Low-level client for the PrincipalAttributeService. @@ -146,28 +151,12 @@ async def create_key( ) return PrincipalAttributeKey._from_proto(response.principal_attribute_key) - async def update_key( - self, - principal_attribute_key_id: str, - *, - display_name: str | None = None, - description: str | None = None, - ) -> PrincipalAttributeKey: - paths: list[str] = [] - request_kwargs: dict[str, Any] = {"principal_attribute_key_id": principal_attribute_key_id} - if display_name is not None: - request_kwargs["display_name"] = display_name - paths.append("display_name") - if description is not None: - request_kwargs["description"] = description - paths.append("description") + async def update_key(self, *, update: PrincipalAttributeKeyUpdate) -> PrincipalAttributeKey: + request, mask = update.to_proto_with_mask() + request.update_mask.CopyFrom(mask) response = cast( "UpdatePrincipalAttributeKeyResponse", - await self._stub.UpdatePrincipalAttributeKey( - UpdatePrincipalAttributeKeyRequest( - update_mask=field_mask_pb2.FieldMask(paths=paths), **request_kwargs - ) - ), + await self._stub.UpdatePrincipalAttributeKey(request), ) return PrincipalAttributeKey._from_proto(response.principal_attribute_key) @@ -346,10 +335,9 @@ async def batch_create_values( enum_value_ids: list[str] | None = None, boolean_value: bool | None = None, number_value: int | None = None, - ) -> list[PrincipalAttributeValue]: + ) -> list[PrincipalAttributeAssignment]: request_kwargs: dict[str, Any] = { "principal_attribute_key_id": key_id, - "principal_ids": principal_ids, "principal_type": principal_type, } if enum_value_ids is not None: @@ -363,20 +351,26 @@ async def batch_create_values( elif number_value is not None: request_kwargs["number_value"] = number_value - response = cast( - "BatchCreatePrincipalAttributeValueResponse", - await self._stub.BatchCreatePrincipalAttributeValue( - BatchCreatePrincipalAttributeValueRequest(**request_kwargs) - ), - ) - return [PrincipalAttributeValue._from_proto(v) for v in response.principal_attribute_values] + values: list[PrincipalAttributeAssignment] = [] + for batch in chunked(principal_ids, _BATCH_SIZE): + response = cast( + "BatchCreatePrincipalAttributeValueResponse", + await self._stub.BatchCreatePrincipalAttributeValue( + BatchCreatePrincipalAttributeValueRequest(principal_ids=batch, **request_kwargs) + ), + ) + values.extend( + PrincipalAttributeAssignment._from_proto(v) + for v in response.principal_attribute_values + ) + return values async def get_value( self, principal_attribute_value_id: str, *, principal_type: PrincipalAttributePrincipalType.ValueType, - ) -> PrincipalAttributeValue: + ) -> PrincipalAttributeAssignment: response = cast( "GetPrincipalAttributeValueResponse", await self._stub.GetPrincipalAttributeValue( @@ -386,7 +380,7 @@ async def get_value( ) ), ) - return PrincipalAttributeValue._from_proto(response.principal_attribute_value) + return PrincipalAttributeAssignment._from_proto(response.principal_attribute_value) async def list_key_values( self, @@ -398,7 +392,7 @@ async def list_key_values( query_filter: str | None = None, order_by: str | None = None, include_archived: bool = False, - ) -> tuple[list[PrincipalAttributeValue], str]: + ) -> tuple[list[PrincipalAttributeAssignment], str]: request_kwargs: dict[str, Any] = { "principal_attribute_key_id": key_id, "principal_type": principal_type, @@ -420,7 +414,7 @@ async def list_key_values( ), ) values = [ - PrincipalAttributeValue._from_proto(v) for v in response.principal_attribute_values + PrincipalAttributeAssignment._from_proto(v) for v in response.principal_attribute_values ] return values, response.next_page_token @@ -434,7 +428,7 @@ async def list_all_key_values( include_archived: bool = False, page_size: int | None = None, max_results: int | None = None, - ) -> list[PrincipalAttributeValue]: + ) -> list[PrincipalAttributeAssignment]: return await self._handle_pagination( self.list_key_values, kwargs={ @@ -457,7 +451,7 @@ async def list_values( query_filter: str | None = None, order_by: str | None = None, include_archived: bool = False, - ) -> tuple[list[PrincipalAttributeValue], str]: + ) -> tuple[list[PrincipalAttributeAssignment], str]: request_kwargs: dict[str, Any] = { "principal_type": principal_type, "include_archived": include_archived, @@ -478,7 +472,7 @@ async def list_values( ), ) values = [ - PrincipalAttributeValue._from_proto(v) for v in response.principal_attribute_values + PrincipalAttributeAssignment._from_proto(v) for v in response.principal_attribute_values ] return values, response.next_page_token @@ -491,7 +485,7 @@ async def list_all_values( include_archived: bool = False, page_size: int | None = None, max_results: int | None = None, - ) -> list[PrincipalAttributeValue]: + ) -> list[PrincipalAttributeAssignment]: return await self._handle_pagination( self.list_values, kwargs={ @@ -510,12 +504,13 @@ async def archive_values( *, principal_type: PrincipalAttributePrincipalType.ValueType, ) -> None: - await self._stub.ArchivePrincipalAttributeValues( - ArchivePrincipalAttributeValuesRequest( - principal_attribute_value_ids=principal_attribute_value_ids, - principal_type=principal_type, + for batch in chunked(principal_attribute_value_ids, _BATCH_SIZE): + await self._stub.ArchivePrincipalAttributeValues( + ArchivePrincipalAttributeValuesRequest( + principal_attribute_value_ids=batch, + principal_type=principal_type, + ) ) - ) async def unarchive_values( self, @@ -523,9 +518,10 @@ async def unarchive_values( *, principal_type: PrincipalAttributePrincipalType.ValueType, ) -> None: - await self._stub.UnarchivePrincipalAttributeValues( - UnarchivePrincipalAttributeValuesRequest( - principal_attribute_value_ids=principal_attribute_value_ids, - principal_type=principal_type, + for batch in chunked(principal_attribute_value_ids, _BATCH_SIZE): + await self._stub.UnarchivePrincipalAttributeValues( + UnarchivePrincipalAttributeValuesRequest( + principal_attribute_value_ids=batch, + principal_type=principal_type, + ) ) - ) diff --git a/python/lib/sift_client/_internal/low_level_wrappers/resource_attributes.py b/python/lib/sift_client/_internal/low_level_wrappers/resource_attributes.py index 05834c2b2..2d4f286b7 100644 --- a/python/lib/sift_client/_internal/low_level_wrappers/resource_attributes.py +++ b/python/lib/sift_client/_internal/low_level_wrappers/resource_attributes.py @@ -38,17 +38,18 @@ UnarchiveResourceAttributeKeyRequest, UpdateResourceAttributeEnumValueRequest, UpdateResourceAttributeEnumValueResponse, - UpdateResourceAttributeKeyRequest, UpdateResourceAttributeKeyResponse, ) from sift.resource_attribute.v1.resource_attribute_pb2_grpc import ResourceAttributeServiceStub from sift_client._internal.low_level_wrappers.base import LowLevelClientBase +from sift_client._internal.util.util import chunked from sift_client.sift_types.resource_attribute import ( - ResourceAttribute, + ResourceAttributeAssignment, ResourceAttributeEntity, ResourceAttributeEnumValue, ResourceAttributeKey, + ResourceAttributeKeyUpdate, ) from sift_client.transport import WithGrpcClient @@ -57,6 +58,10 @@ logger = logging.getLogger(__name__) +# Max entities/IDs per batch RPC; keeps request bodies well under gRPC message +# size limits when operating on large resource sets. +_BATCH_SIZE = 1000 + class ResourceAttributesLowLevelClient(LowLevelClientBase, WithGrpcClient): """Low-level client for the ResourceAttributeService. @@ -129,11 +134,11 @@ async def create_key( self, *, display_name: str, - key_type: ResourceAttributeKeyType.ValueType, + value_type: ResourceAttributeKeyType.ValueType, description: str = "", ) -> ResourceAttributeKey: request = CreateResourceAttributeKeyRequest( - display_name=display_name, description=description, type=key_type + display_name=display_name, description=description, type=value_type ) response = cast( "CreateResourceAttributeKeyResponse", @@ -141,24 +146,9 @@ async def create_key( ) return ResourceAttributeKey._from_proto(response.resource_attribute_key) - async def update_key( - self, - resource_attribute_key_id: str, - *, - display_name: str | None = None, - description: str | None = None, - ) -> ResourceAttributeKey: - paths: list[str] = [] - request_kwargs: dict[str, Any] = {"resource_attribute_key_id": resource_attribute_key_id} - if display_name is not None: - request_kwargs["display_name"] = display_name - paths.append("display_name") - if description is not None: - request_kwargs["description"] = description - paths.append("description") - request = UpdateResourceAttributeKeyRequest( - update_mask=field_mask_pb2.FieldMask(paths=paths), **request_kwargs - ) + async def update_key(self, *, update: ResourceAttributeKeyUpdate) -> ResourceAttributeKey: + request, mask = update.to_proto_with_mask() + request.update_mask.CopyFrom(mask) response = cast( "UpdateResourceAttributeKeyResponse", await self._stub.UpdateResourceAttributeKey(request), @@ -331,11 +321,8 @@ async def batch_create_resource_attributes( enum_value_ids: list[str] | None = None, boolean_value: bool | None = None, number_value: int | None = None, - ) -> list[ResourceAttribute]: - request_kwargs: dict[str, Any] = { - "resource_attribute_key_id": key_id, - "entities": [e._to_proto() for e in entities], - } + ) -> list[ResourceAttributeAssignment]: + request_kwargs: dict[str, Any] = {"resource_attribute_key_id": key_id} if enum_value_ids is not None: request_kwargs["resource_attribute_enum_value_ids"] = ResourceAttributeEnumValueIdList( ids=enum_value_ids @@ -347,22 +334,31 @@ async def batch_create_resource_attributes( elif number_value is not None: request_kwargs["number_value"] = number_value - response = cast( - "BatchCreateResourceAttributesResponse", - await self._stub.BatchCreateResourceAttributes( - BatchCreateResourceAttributesRequest(**request_kwargs) - ), - ) - return [ResourceAttribute._from_proto(a) for a in response.resource_attributes] + attrs: list[ResourceAttributeAssignment] = [] + for batch in chunked(entities, _BATCH_SIZE): + response = cast( + "BatchCreateResourceAttributesResponse", + await self._stub.BatchCreateResourceAttributes( + BatchCreateResourceAttributesRequest( + entities=[e._to_proto() for e in batch], **request_kwargs + ) + ), + ) + attrs.extend( + ResourceAttributeAssignment._from_proto(a) for a in response.resource_attributes + ) + return attrs - async def get_resource_attribute(self, resource_attribute_id: str) -> ResourceAttribute: + async def get_resource_attribute( + self, resource_attribute_id: str + ) -> ResourceAttributeAssignment: response = cast( "GetResourceAttributeResponse", await self._stub.GetResourceAttribute( GetResourceAttributeRequest(resource_attribute_id=resource_attribute_id) ), ) - return ResourceAttribute._from_proto(response.resource_attribute) + return ResourceAttributeAssignment._from_proto(response.resource_attribute) async def list_resource_attributes( self, @@ -372,7 +368,7 @@ async def list_resource_attributes( query_filter: str | None = None, order_by: str | None = None, include_archived: bool = False, - ) -> tuple[list[ResourceAttribute], str]: + ) -> tuple[list[ResourceAttributeAssignment], str]: request_kwargs: dict[str, Any] = {"include_archived": include_archived} if page_size: request_kwargs["page_size"] = page_size @@ -389,7 +385,7 @@ async def list_resource_attributes( ListResourceAttributesRequest(**request_kwargs) ), ) - attrs = [ResourceAttribute._from_proto(a) for a in response.resource_attributes] + attrs = [ResourceAttributeAssignment._from_proto(a) for a in response.resource_attributes] return attrs, response.next_page_token async def list_all_resource_attributes( @@ -400,7 +396,7 @@ async def list_all_resource_attributes( include_archived: bool = False, page_size: int | None = None, max_results: int | None = None, - ) -> list[ResourceAttribute]: + ) -> list[ResourceAttributeAssignment]: return await self._handle_pagination( self.list_resource_attributes, kwargs={"query_filter": query_filter, "include_archived": include_archived}, @@ -417,7 +413,7 @@ async def list_resource_attributes_by_entity( page_token: str | None = None, order_by: str | None = None, # unsupported by this RPC; accepted for pagination helper include_archived: bool = False, - ) -> tuple[list[ResourceAttribute], str]: + ) -> tuple[list[ResourceAttributeAssignment], str]: request_kwargs: dict[str, Any] = { "entity": entity._to_proto(), "include_archived": include_archived, @@ -433,7 +429,7 @@ async def list_resource_attributes_by_entity( ListResourceAttributesByEntityRequest(**request_kwargs) ), ) - attrs = [ResourceAttribute._from_proto(a) for a in response.resource_attributes] + attrs = [ResourceAttributeAssignment._from_proto(a) for a in response.resource_attributes] return attrs, response.next_page_token async def list_all_resource_attributes_by_entity( @@ -443,7 +439,7 @@ async def list_all_resource_attributes_by_entity( include_archived: bool = False, page_size: int | None = None, max_results: int | None = None, - ) -> list[ResourceAttribute]: + ) -> list[ResourceAttributeAssignment]: return await self._handle_pagination( self.list_resource_attributes_by_entity, kwargs={"entity": entity, "include_archived": include_archived}, @@ -452,11 +448,13 @@ async def list_all_resource_attributes_by_entity( ) async def batch_archive_resource_attributes(self, resource_attribute_ids: list[str]) -> None: - await self._stub.BatchArchiveResourceAttributes( - BatchArchiveResourceAttributesRequest(resource_attribute_ids=resource_attribute_ids) - ) + for batch in chunked(resource_attribute_ids, _BATCH_SIZE): + await self._stub.BatchArchiveResourceAttributes( + BatchArchiveResourceAttributesRequest(resource_attribute_ids=batch) + ) async def batch_unarchive_resource_attributes(self, resource_attribute_ids: list[str]) -> None: - await self._stub.BatchUnarchiveResourceAttributes( - BatchUnarchiveResourceAttributesRequest(resource_attribute_ids=resource_attribute_ids) - ) + for batch in chunked(resource_attribute_ids, _BATCH_SIZE): + await self._stub.BatchUnarchiveResourceAttributes( + BatchUnarchiveResourceAttributesRequest(resource_attribute_ids=batch) + ) diff --git a/python/lib/sift_client/_internal/low_level_wrappers/users.py b/python/lib/sift_client/_internal/low_level_wrappers/users.py new file mode 100644 index 000000000..450c79cf8 --- /dev/null +++ b/python/lib/sift_client/_internal/low_level_wrappers/users.py @@ -0,0 +1,130 @@ +from __future__ import annotations + +import logging +from typing import TYPE_CHECKING, Any, cast + +from sift.users.v2.users_pb2 import ( + GetUserRequest, + GetUserResponse, + ListActiveUsersRequest, + ListActiveUsersResponse, + ListUsersRequest, + ListUsersResponse, +) +from sift.users.v2.users_pb2_grpc import UserServiceStub + +from sift_client._internal.low_level_wrappers.base import LowLevelClientBase +from sift_client.sift_types.user import User +from sift_client.transport import WithGrpcClient + +if TYPE_CHECKING: + from sift_client.transport.grpc_transport import GrpcClient + +logger = logging.getLogger(__name__) + + +class UsersLowLevelClient(LowLevelClientBase, WithGrpcClient): + """Low-level client for the UserService. + + A thin wrapper around the autogenerated bindings; each method maps to a single RPC. + """ + + def __init__(self, grpc_client: GrpcClient): + super().__init__(grpc_client) + + @property + def _stub(self) -> UserServiceStub: + return self._grpc_client.get_stub(UserServiceStub) + + async def get_user(self, user_id: str) -> User: + response = cast( + "GetUserResponse", + await self._stub.GetUser(GetUserRequest(user_id=user_id)), + ) + return User._from_proto(response.user) + + async def list_active_users( + self, + *, + page_size: int | None = None, + page_token: str | None = None, + query_filter: str | None = None, + order_by: str | None = None, + organization_id: str | None = None, + ) -> tuple[list[User], str]: + request_kwargs: dict[str, Any] = {} + if page_size: + request_kwargs["page_size"] = page_size + if page_token: + request_kwargs["page_token"] = page_token + if query_filter: + request_kwargs["filter"] = query_filter + if order_by: + request_kwargs["order_by"] = order_by + if organization_id: + request_kwargs["organization_id"] = organization_id + + response = cast( + "ListActiveUsersResponse", + await self._stub.ListActiveUsers(ListActiveUsersRequest(**request_kwargs)), + ) + users = [User._from_proto(u) for u in response.users] + return users, response.next_page_token + + async def list_all_active_users( + self, + *, + query_filter: str | None = None, + order_by: str | None = None, + organization_id: str | None = None, + page_size: int | None = None, + max_results: int | None = None, + ) -> list[User]: + return await self._handle_pagination( + self.list_active_users, + kwargs={"query_filter": query_filter, "organization_id": organization_id}, + page_size=page_size, + order_by=order_by, + max_results=max_results, + ) + + async def list_users( + self, + *, + page_size: int | None = None, + page_token: str | None = None, + query_filter: str | None = None, + order_by: str | None = None, + ) -> tuple[list[User], str]: + request_kwargs: dict[str, Any] = {} + if page_size: + request_kwargs["page_size"] = page_size + if page_token: + request_kwargs["page_token"] = page_token + if query_filter: + request_kwargs["filter"] = query_filter + if order_by: + request_kwargs["order_by"] = order_by + + response = cast( + "ListUsersResponse", + await self._stub.ListUsers(ListUsersRequest(**request_kwargs)), + ) + users = [User._from_proto(u) for u in response.users] + return users, response.next_page_token + + async def list_all_users( + self, + *, + query_filter: str | None = None, + order_by: str | None = None, + page_size: int | None = None, + max_results: int | None = None, + ) -> list[User]: + return await self._handle_pagination( + self.list_users, + kwargs={"query_filter": query_filter}, + page_size=page_size, + order_by=order_by, + max_results=max_results, + ) diff --git a/python/lib/sift_client/_internal/util/util.py b/python/lib/sift_client/_internal/util/util.py index 15b33847e..28f69ef92 100644 --- a/python/lib/sift_client/_internal/util/util.py +++ b/python/lib/sift_client/_internal/util/util.py @@ -1,6 +1,17 @@ -from typing import Any +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +if TYPE_CHECKING: + from collections.abc import Iterator def count_non_none(*args: Any) -> int: """Count the number of non-none arguments.""" return sum(1 for arg in args if arg is not None) + + +def chunked(items: list[Any], size: int) -> Iterator[list[Any]]: + """Yield successive chunks of at most ``size`` items.""" + for i in range(0, len(items), size): + yield items[i : i + size] diff --git a/python/lib/sift_client/_tests/_internal/low_level_wrappers/test_principal_attributes.py b/python/lib/sift_client/_tests/_internal/low_level_wrappers/test_principal_attributes.py index 73dc97a49..41a55aae3 100644 --- a/python/lib/sift_client/_tests/_internal/low_level_wrappers/test_principal_attributes.py +++ b/python/lib/sift_client/_tests/_internal/low_level_wrappers/test_principal_attributes.py @@ -108,3 +108,69 @@ async def test_passes_key_and_principal_type(self): request = stub.ListPrincipalAttributeKeyValues.call_args[0][0] assert request.principal_attribute_key_id == "pk1" assert request.principal_type == pa.PRINCIPAL_ATTRIBUTE_PRINCIPAL_TYPE_USER + + +class TestBatchCreateValuesChunking: + @pytest.mark.asyncio + async def test_splits_principal_ids_over_batch_size_into_multiple_rpcs(self): + stub = MagicMock() + stub.BatchCreatePrincipalAttributeValue = AsyncMock( + return_value=pa.BatchCreatePrincipalAttributeValueResponse( + principal_attribute_values=[ + pa.PrincipalAttributeValue(principal_attribute_value_id="v1") + ] + ) + ) + client = _client_with_stub(stub) + + values = await client.batch_create_values( + key_id="pk1", + principal_ids=[f"u{i}" for i in range(1001)], + principal_type=pa.PRINCIPAL_ATTRIBUTE_PRINCIPAL_TYPE_USER, + boolean_value=True, + ) + + assert stub.BatchCreatePrincipalAttributeValue.call_count == 2 + first = stub.BatchCreatePrincipalAttributeValue.call_args_list[0][0][0] + second = stub.BatchCreatePrincipalAttributeValue.call_args_list[1][0][0] + assert len(first.principal_ids) == 1000 + assert list(second.principal_ids) == ["u1000"] + # One value per RPC response, concatenated across chunks. + assert len(values) == 2 + + +class TestArchiveValuesChunking: + @pytest.mark.asyncio + async def test_splits_value_ids_over_batch_size_into_multiple_rpcs(self): + stub = MagicMock() + stub.ArchivePrincipalAttributeValues = AsyncMock( + return_value=pa.ArchivePrincipalAttributeValuesResponse() + ) + client = _client_with_stub(stub) + + await client.archive_values( + [f"v{i}" for i in range(1001)], + principal_type=pa.PRINCIPAL_ATTRIBUTE_PRINCIPAL_TYPE_USER, + ) + + assert stub.ArchivePrincipalAttributeValues.call_count == 2 + second = stub.ArchivePrincipalAttributeValues.call_args_list[1][0][0] + assert list(second.principal_attribute_value_ids) == ["v1000"] + + +class TestBatchCreateValuesEmptyInput: + @pytest.mark.asyncio + async def test_empty_principal_ids_makes_no_rpc(self): + stub = MagicMock() + stub.BatchCreatePrincipalAttributeValue = AsyncMock() + client = _client_with_stub(stub) + + values = await client.batch_create_values( + key_id="pk1", + principal_ids=[], + principal_type=pa.PRINCIPAL_ATTRIBUTE_PRINCIPAL_TYPE_USER, + boolean_value=True, + ) + + assert values == [] + stub.BatchCreatePrincipalAttributeValue.assert_not_called() diff --git a/python/lib/sift_client/_tests/_internal/low_level_wrappers/test_resource_attributes.py b/python/lib/sift_client/_tests/_internal/low_level_wrappers/test_resource_attributes.py index f7ec36502..38cf6258f 100644 --- a/python/lib/sift_client/_tests/_internal/low_level_wrappers/test_resource_attributes.py +++ b/python/lib/sift_client/_tests/_internal/low_level_wrappers/test_resource_attributes.py @@ -8,7 +8,10 @@ from sift_client._internal.low_level_wrappers.resource_attributes import ( ResourceAttributesLowLevelClient, ) -from sift_client.sift_types.resource_attribute import ResourceAttributeEntity +from sift_client.sift_types.resource_attribute import ( + ResourceAttributeEntity, + ResourceAttributeKeyUpdate, +) def _client_with_stub(stub: MagicMock) -> ResourceAttributesLowLevelClient: @@ -31,7 +34,7 @@ async def test_sends_type_and_returns_key(self): client = _client_with_stub(stub) key = await client.create_key( - display_name="licenses", key_type=ra.RESOURCE_ATTRIBUTE_KEY_TYPE_SET_OF_ENUM + display_name="licenses", value_type=ra.RESOURCE_ATTRIBUTE_KEY_TYPE_SET_OF_ENUM ) request = stub.CreateResourceAttributeKey.call_args[0][0] @@ -51,11 +54,14 @@ async def test_only_sets_provided_fields_in_mask(self): ) client = _client_with_stub(stub) - await client.update_key("k1", description="new desc") + update = ResourceAttributeKeyUpdate(description="new desc") + update.resource_id = "k1" + await client.update_key(update=update) request = stub.UpdateResourceAttributeKey.call_args[0][0] assert list(request.update_mask.paths) == ["description"] assert request.description == "new desc" + assert request.resource_attribute_key_id == "k1" class TestListAllKeys: @@ -158,3 +164,60 @@ async def test_uses_single_enum_value_id(self): request = stub.BatchCreateResourceAttributes.call_args[0][0] assert request.resource_attribute_enum_value_id == "ev1" assert not request.HasField("resource_attribute_enum_value_ids") + + +class TestBatchCreateResourceAttributesChunking: + @pytest.mark.asyncio + async def test_splits_entities_over_batch_size_into_multiple_rpcs(self): + stub = MagicMock() + stub.BatchCreateResourceAttributes = AsyncMock( + return_value=ra.BatchCreateResourceAttributesResponse( + resource_attributes=[ra.ResourceAttribute(resource_attribute_id="a1")] + ) + ) + client = _client_with_stub(stub) + + attrs = await client.batch_create_resource_attributes( + key_id="k1", + entities=[ResourceAttributeEntity.for_asset(f"ast{i}") for i in range(1001)], + boolean_value=True, + ) + + assert stub.BatchCreateResourceAttributes.call_count == 2 + first = stub.BatchCreateResourceAttributes.call_args_list[0][0][0] + second = stub.BatchCreateResourceAttributes.call_args_list[1][0][0] + assert len(first.entities) == 1000 + assert [e.entity_id for e in second.entities] == ["ast1000"] + # One attribute per RPC response, concatenated across chunks. + assert len(attrs) == 2 + + +class TestBatchArchiveResourceAttributesChunking: + @pytest.mark.asyncio + async def test_splits_ids_over_batch_size_into_multiple_rpcs(self): + stub = MagicMock() + stub.BatchArchiveResourceAttributes = AsyncMock( + return_value=ra.BatchArchiveResourceAttributesResponse() + ) + client = _client_with_stub(stub) + + await client.batch_archive_resource_attributes([f"a{i}" for i in range(1001)]) + + assert stub.BatchArchiveResourceAttributes.call_count == 2 + second = stub.BatchArchiveResourceAttributes.call_args_list[1][0][0] + assert list(second.resource_attribute_ids) == ["a1000"] + + +class TestBatchCreateResourceAttributesEmptyInput: + @pytest.mark.asyncio + async def test_empty_entities_makes_no_rpc(self): + stub = MagicMock() + stub.BatchCreateResourceAttributes = AsyncMock() + client = _client_with_stub(stub) + + attrs = await client.batch_create_resource_attributes( + key_id="k1", entities=[], boolean_value=True + ) + + assert attrs == [] + stub.BatchCreateResourceAttributes.assert_not_called() diff --git a/python/lib/sift_client/_tests/_internal/low_level_wrappers/test_users.py b/python/lib/sift_client/_tests/_internal/low_level_wrappers/test_users.py new file mode 100644 index 000000000..6b33cdf0c --- /dev/null +++ b/python/lib/sift_client/_tests/_internal/low_level_wrappers/test_users.py @@ -0,0 +1,85 @@ +"""Tests for the users low-level wrapper.""" + +from unittest.mock import AsyncMock, MagicMock + +import pytest +from sift.common.type.v1.user_pb2 import User as UserProto +from sift.users.v2 import users_pb2 + +from sift_client._internal.low_level_wrappers.users import UsersLowLevelClient + + +def _client_with_stub(stub: MagicMock) -> UsersLowLevelClient: + grpc = MagicMock() + grpc.get_stub.return_value = stub + return UsersLowLevelClient(grpc) + + +class TestGetUser: + @pytest.mark.asyncio + async def test_returns_user(self): + stub = MagicMock() + stub.GetUser = AsyncMock( + return_value=users_pb2.GetUserResponse( + user=UserProto(user_id="u1", user_name="alice@example.com") + ) + ) + client = _client_with_stub(stub) + + user = await client.get_user("u1") + + request = stub.GetUser.call_args[0][0] + assert request.user_id == "u1" + assert user.id_ == "u1" + assert user.name == "alice@example.com" + + +class TestListAllActiveUsers: + @pytest.mark.asyncio + async def test_follows_pagination(self): + stub = MagicMock() + stub.ListActiveUsers = AsyncMock( + side_effect=[ + users_pb2.ListActiveUsersResponse( + users=[UserProto(user_id="u1", user_name="a@x.com")], + next_page_token="tok", + ), + users_pb2.ListActiveUsersResponse( + users=[UserProto(user_id="u2", user_name="b@x.com")], + next_page_token="", + ), + ] + ) + client = _client_with_stub(stub) + + users = await client.list_all_active_users() + + assert [u.id_ for u in users] == ["u1", "u2"] + assert stub.ListActiveUsers.call_count == 2 + + @pytest.mark.asyncio + async def test_passes_filter_and_organization_id(self): + stub = MagicMock() + stub.ListActiveUsers = AsyncMock( + return_value=users_pb2.ListActiveUsersResponse(next_page_token="") + ) + client = _client_with_stub(stub) + + await client.list_all_active_users(query_filter='name == "a@x.com"', organization_id="org1") + + request = stub.ListActiveUsers.call_args[0][0] + assert request.filter == 'name == "a@x.com"' + assert request.organization_id == "org1" + + +class TestListAllUsers: + @pytest.mark.asyncio + async def test_uses_list_users_rpc(self): + stub = MagicMock() + stub.ListUsers = AsyncMock(return_value=users_pb2.ListUsersResponse(next_page_token="")) + client = _client_with_stub(stub) + + await client.list_all_users(query_filter='user_id == "u1"') + + request = stub.ListUsers.call_args[0][0] + assert request.filter == 'user_id == "u1"' diff --git a/python/lib/sift_client/_tests/resources/test_base.py b/python/lib/sift_client/_tests/resources/test_base.py new file mode 100644 index 000000000..5b90233c3 --- /dev/null +++ b/python/lib/sift_client/_tests/resources/test_base.py @@ -0,0 +1,35 @@ +"""Unit tests for the shared CEL filter builders on ResourceBase.""" + +from unittest.mock import MagicMock + +from sift_client.resources._base import ResourceBase +from sift_client.util import cel_utils as cel + + +def _base() -> ResourceBase: + return ResourceBase(MagicMock()) + + +class TestBuildTimeCelFilters: + def test_modified_by_filters_on_modified_by_value(self): + # Regression: the modified_by branch used to emit the created_by value. + parts = _base()._build_time_cel_filters(modified_by="u2") + + assert parts == [cel.equals("modified_by_user_id", "u2")] + + def test_created_by_filters_on_created_by_value(self): + parts = _base()._build_time_cel_filters(created_by="u1") + + assert parts == [cel.equals("created_by_user_id", "u1")] + + +class TestBuildNameCelFilters: + def test_defaults_to_name_field(self): + parts = _base()._build_name_cel_filters(name="licenses") + + assert parts == [cel.equals("name", "licenses")] + + def test_field_overrides_the_cel_field(self): + parts = _base()._build_name_cel_filters(name="licenses", field="display_name") + + assert parts == [cel.equals("display_name", "licenses")] diff --git a/python/lib/sift_client/_tests/resources/test_principal_attributes.py b/python/lib/sift_client/_tests/resources/test_principal_attributes.py index 8938da3b9..1671cbba3 100644 --- a/python/lib/sift_client/_tests/resources/test_principal_attributes.py +++ b/python/lib/sift_client/_tests/resources/test_principal_attributes.py @@ -1,6 +1,6 @@ """Unit tests for the principal attributes high-level API. -These mock the low-level client and the REST client; they are not integration tests. +These mock the low-level client and the users API; they are not integration tests. """ from unittest.mock import AsyncMock, MagicMock @@ -11,6 +11,7 @@ from sift_client.resources.access_control.principal_attributes import PrincipalAttributesAPIAsync from sift_client.sift_types.principal_attribute import ( PrincipalAttributeKey, + PrincipalAttributeKeyUpdate, PrincipalType, ) @@ -32,39 +33,6 @@ def _key() -> PrincipalAttributeKey: ) -def _users_response(users): - response = MagicMock() - response.raise_for_status = MagicMock() - response.json = MagicMock(return_value={"users": users, "nextPageToken": ""}) - return response - - -class TestResolveUserIds: - @pytest.mark.asyncio - async def test_maps_email_to_user_id_via_rest(self): - api = _api() - api.client.rest_client.get = MagicMock( - return_value=_users_response( - [ - {"userName": "alice@x.com", "userId": "u1"}, - {"userName": "bob@x.com", "userId": "u2"}, - ] - ) - ) - - resolved = await api.resolve_user_ids(["alice@x.com"]) - - assert resolved == {"alice@x.com": "u1"} - - @pytest.mark.asyncio - async def test_resolve_user_id_raises_when_missing(self): - api = _api() - api.client.rest_client.get = MagicMock(return_value=_users_response([])) - - with pytest.raises(ValueError, match="No user found"): - await api.resolve_user_id("ghost@x.com") - - class TestAssign: @pytest.mark.asyncio async def test_fetches_key_when_assigning_by_key_id(self): @@ -81,15 +49,14 @@ async def test_fetches_key_when_assigning_by_key_id(self): assert kwargs["enum_value_ids"] == ["e_a"] @pytest.mark.asyncio - async def test_resolves_emails_and_keeps_raw_ids(self): + async def test_resolves_emails_via_users_api_and_keeps_raw_ids(self): api = _api() - api.client.rest_client.get = MagicMock( - return_value=_users_response([{"userName": "alice@x.com", "userId": "u1"}]) - ) + api.client.async_.users.resolve_ids = AsyncMock(return_value={"alice@x.com": "u1"}) api._low_level_client.batch_create_values = AsyncMock(return_value=[]) await api.assign(_key(), ["alice@x.com", "raw_id"], value=["e_a"]) + api.client.async_.users.resolve_ids.assert_awaited_once_with(["alice@x.com"]) kwargs = api._low_level_client.batch_create_values.call_args.kwargs assert kwargs["principal_ids"] == ["u1", "raw_id"] assert kwargs["principal_type"] == PrincipalType.USER.value @@ -98,7 +65,7 @@ async def test_resolves_emails_and_keeps_raw_ids(self): @pytest.mark.asyncio async def test_unresolvable_email_raises(self): api = _api() - api.client.rest_client.get = MagicMock(return_value=_users_response([])) + api.client.async_.users.resolve_ids = AsyncMock(return_value={}) with pytest.raises(ValueError, match="No user found"): await api.assign(_key(), ["ghost@x.com"], value=["e_a"]) @@ -133,3 +100,17 @@ async def test_uses_all_values_rpc_without_key(self): await api.list_assignments() api._low_level_client.list_all_values.assert_awaited_once() + + +class TestUpdateKey: + @pytest.mark.asyncio + async def test_dict_update_is_validated_and_carries_key_id(self): + api = _api() + api._low_level_client.update_key = AsyncMock(return_value=_key()) + + await api.update_key("pk1", {"display_name": "new name"}) + + update = api._low_level_client.update_key.call_args.kwargs["update"] + assert isinstance(update, PrincipalAttributeKeyUpdate) + assert update.display_name == "new name" + assert update.resource_id == "pk1" diff --git a/python/lib/sift_client/_tests/resources/test_resource_attributes.py b/python/lib/sift_client/_tests/resources/test_resource_attributes.py index 71021a8e7..2c83d102d 100644 --- a/python/lib/sift_client/_tests/resources/test_resource_attributes.py +++ b/python/lib/sift_client/_tests/resources/test_resource_attributes.py @@ -17,7 +17,8 @@ ResourceAttributeEntityType, ResourceAttributeEnumValue, ResourceAttributeKey, - ResourceAttributeKeyType, + ResourceAttributeKeyUpdate, + ResourceAttributeValueType, ) @@ -28,10 +29,10 @@ def _api() -> ResourceAttributesAPIAsync: return api -def _key(key_type=ra.RESOURCE_ATTRIBUTE_KEY_TYPE_SET_OF_ENUM) -> ResourceAttributeKey: +def _key(value_type=ra.RESOURCE_ATTRIBUTE_KEY_TYPE_SET_OF_ENUM) -> ResourceAttributeKey: return ResourceAttributeKey._from_proto( ra.ResourceAttributeKey( - resource_attribute_key_id="k1", display_name="licenses", type=key_type + resource_attribute_key_id="k1", display_name="licenses", type=value_type ) ) @@ -55,7 +56,7 @@ async def test_returns_existing_without_creating(self): api._low_level_client.list_all_keys = AsyncMock(return_value=[_key()]) api._low_level_client.create_key = AsyncMock(side_effect=AssertionError("must not create")) - key = await api.get_or_create_key("licenses", ResourceAttributeKeyType.SET_OF_ENUM) + key = await api.get_or_create_key("licenses", ResourceAttributeValueType.SET_OF_ENUM) assert key.id_ == "k1" @@ -65,7 +66,7 @@ async def test_creates_when_missing(self): api._low_level_client.list_all_keys = AsyncMock(return_value=[]) api._low_level_client.create_key = AsyncMock(return_value=_key()) - key = await api.get_or_create_key("licenses", ResourceAttributeKeyType.SET_OF_ENUM) + key = await api.get_or_create_key("licenses", ResourceAttributeValueType.SET_OF_ENUM) api._low_level_client.create_key.assert_awaited_once() assert key.id_ == "k1" @@ -218,3 +219,28 @@ def _asset_proto(): proto = AssetProto(asset_id="a1", name="asset") return proto + + +class TestUpdateKey: + @pytest.mark.asyncio + async def test_dict_update_is_validated_and_carries_key_id(self): + api = _api() + api._low_level_client.update_key = AsyncMock(return_value=_key()) + + await api.update_key("k1", {"display_name": "new name"}) + + update = api._low_level_client.update_key.call_args.kwargs["update"] + assert isinstance(update, ResourceAttributeKeyUpdate) + assert update.display_name == "new name" + assert update.resource_id == "k1" + + @pytest.mark.asyncio + async def test_model_update_takes_id_from_key_object(self): + api = _api() + api._low_level_client.update_key = AsyncMock(return_value=_key()) + + await api.update_key(_key(), ResourceAttributeKeyUpdate(description="d")) + + update = api._low_level_client.update_key.call_args.kwargs["update"] + assert update.resource_id == "k1" + assert update.description == "d" diff --git a/python/lib/sift_client/_tests/resources/test_users.py b/python/lib/sift_client/_tests/resources/test_users.py new file mode 100644 index 000000000..bbf0f44e9 --- /dev/null +++ b/python/lib/sift_client/_tests/resources/test_users.py @@ -0,0 +1,123 @@ +"""Unit tests for the users high-level API. These mock the low-level client.""" + +from unittest.mock import AsyncMock, MagicMock + +import pytest +from sift.common.type.v1.user_pb2 import User as UserProto + +from sift_client.resources.users import UsersAPIAsync +from sift_client.sift_types.user import User +from sift_client.util import cel_utils as cel + + +def _api() -> UsersAPIAsync: + client = MagicMock() + api = UsersAPIAsync(client) + api._low_level_client = MagicMock() + return api + + +def _user(user_id: str, name: str) -> User: + return User._from_proto(UserProto(user_id=user_id, user_name=name)) + + +class TestList: + @pytest.mark.asyncio + async def test_defaults_to_active_users(self): + api = _api() + api._low_level_client.list_all_active_users = AsyncMock(return_value=[]) + + await api.list_() + + api._low_level_client.list_all_active_users.assert_awaited_once() + + @pytest.mark.asyncio + async def test_include_inactive_uses_all_users_rpc(self): + api = _api() + api._low_level_client.list_all_users = AsyncMock(return_value=[]) + + await api.list_(include_inactive=True) + + api._low_level_client.list_all_users.assert_awaited_once() + + @pytest.mark.asyncio + async def test_names_become_cel_filter(self): + api = _api() + api._low_level_client.list_all_active_users = AsyncMock(return_value=[]) + + await api.list_(names=["a@x.com", "b@x.com"]) + + kwargs = api._low_level_client.list_all_active_users.call_args.kwargs + assert kwargs["query_filter"] == cel.in_("name", ["a@x.com", "b@x.com"]) + + @pytest.mark.asyncio + async def test_include_inactive_with_organization_id_raises(self): + api = _api() + + with pytest.raises(ValueError, match="organization_id"): + await api.list_(include_inactive=True, organization_id="org1") + + +class TestFind: + @pytest.mark.asyncio + async def test_returns_none_when_no_match(self): + api = _api() + api._low_level_client.list_all_active_users = AsyncMock(return_value=[]) + + assert await api.find(name="ghost@x.com") is None + + @pytest.mark.asyncio + async def test_raises_on_multiple_matches(self): + api = _api() + api._low_level_client.list_all_active_users = AsyncMock( + return_value=[_user("u1", "a@x.com"), _user("u2", "a@x.com")] + ) + + with pytest.raises(ValueError, match="Multiple"): + await api.find(name="a@x.com") + + +class TestResolveIds: + @pytest.mark.asyncio + async def test_maps_emails_and_omits_missing(self): + api = _api() + api._low_level_client.list_all_active_users = AsyncMock( + return_value=[_user("u1", "alice@x.com")] + ) + + resolved = await api.resolve_ids(["alice@x.com", "ghost@x.com"]) + + assert resolved == {"alice@x.com": "u1"} + + @pytest.mark.asyncio + async def test_empty_input_makes_no_call(self): + api = _api() + api._low_level_client.list_all_active_users = AsyncMock() + + assert await api.resolve_ids([]) == {} + api._low_level_client.list_all_active_users.assert_not_called() + + @pytest.mark.asyncio + async def test_deduplicates_emails(self): + api = _api() + api._low_level_client.list_all_active_users = AsyncMock( + return_value=[_user("u1", "alice@x.com")] + ) + + await api.resolve_ids(["alice@x.com", "alice@x.com"]) + + kwargs = api._low_level_client.list_all_active_users.call_args.kwargs + assert kwargs["query_filter"] == cel.in_("name", ["alice@x.com"]) + + +class TestGet: + @pytest.mark.asyncio + async def test_delegates_to_low_level_and_binds_client(self): + api = _api() + api._low_level_client.get_user = AsyncMock(return_value=_user("u1", "alice@x.com")) + + user = await api.get(user_id="u1") + + api._low_level_client.get_user.assert_awaited_once_with("u1") + assert user.id_ == "u1" + assert user._client is api.client diff --git a/python/lib/sift_client/_tests/sift_types/test_principal_attribute.py b/python/lib/sift_client/_tests/sift_types/test_principal_attribute.py index 84ed17e7e..0db74b384 100644 --- a/python/lib/sift_client/_tests/sift_types/test_principal_attribute.py +++ b/python/lib/sift_client/_tests/sift_types/test_principal_attribute.py @@ -4,8 +4,10 @@ from sift.principal_attributes.v1 import principal_attributes_pb2 as pa from sift_client.sift_types.principal_attribute import ( + PrincipalAttributeAssignment, + PrincipalAttributeEnumValue, PrincipalAttributeKey, - PrincipalAttributeValue, + PrincipalAttributeKeyUpdate, PrincipalAttributeValueType, PrincipalType, ) @@ -33,7 +35,7 @@ def test_str_is_display_name(self): assert str(PrincipalAttributeKey._from_proto(_key_proto())) == "licenses" -class TestPrincipalAttributeValue: +class TestPrincipalAttributeAssignment: def test_from_proto_maps_principal_and_flattens_oneof(self): proto = pa.PrincipalAttributeValue( principal_attribute_value_id="v1", @@ -42,7 +44,7 @@ def test_from_proto_maps_principal_and_flattens_oneof(self): principal_type=pa.PRINCIPAL_ATTRIBUTE_PRINCIPAL_TYPE_USER, principal_attribute_enum_value_id="ev1", ) - value = PrincipalAttributeValue._from_proto(proto) + value = PrincipalAttributeAssignment._from_proto(proto) assert value.principal_id == "u1" assert value.principal_type == PrincipalType.USER assert value.enum_value_id == "ev1" @@ -56,7 +58,7 @@ def test_archive_passes_principal_type_and_refreshes(self, mock_client): principal_type=pa.PRINCIPAL_ATTRIBUTE_PRINCIPAL_TYPE_USER, boolean_value=True, ) - value = PrincipalAttributeValue._from_proto(proto) + value = PrincipalAttributeAssignment._from_proto(proto) value._apply_client_to_instance(mock_client) archived_proto = pa.PrincipalAttributeValue( principal_attribute_value_id="v1", @@ -67,7 +69,7 @@ def test_archive_passes_principal_type_and_refreshes(self, mock_client): is_archived=True, ) mock_client.access_control.principal_attributes.get_assignment.return_value = ( - PrincipalAttributeValue._from_proto(archived_proto) + PrincipalAttributeAssignment._from_proto(archived_proto) ) result = value.archive() @@ -90,7 +92,7 @@ def test_apply_client_cascades_to_nested_key_and_enum_value(self, mock_client): principal_attribute_enum_value_id="ev1", display_name="LIC_A" ), ) - value = PrincipalAttributeValue._from_proto(proto) + value = PrincipalAttributeAssignment._from_proto(proto) value._apply_client_to_instance(mock_client) # Nested objects must also carry the client so their convenience methods work. @@ -116,3 +118,66 @@ def test_client_required(self): key = PrincipalAttributeKey._from_proto(_key_proto()) with pytest.raises(AttributeError, match="Sift client not set"): key.check_archive_impact() + + +class TestPrincipalAttributeAssignmentValueProperty: + def test_value_returns_enum_value_id_when_details_missing(self): + proto = pa.PrincipalAttributeValue( + principal_attribute_value_id="v1", + principal_attribute_key_id="pk1", + principal_id="u1", + principal_type=pa.PRINCIPAL_ATTRIBUTE_PRINCIPAL_TYPE_USER, + principal_attribute_enum_value_id="ev1", + ) + assert PrincipalAttributeAssignment._from_proto(proto).value == "ev1" + + def test_value_preserves_false_boolean(self): + proto = pa.PrincipalAttributeValue( + principal_attribute_value_id="v1", + principal_attribute_key_id="pk1", + principal_id="u1", + principal_type=pa.PRINCIPAL_ATTRIBUTE_PRINCIPAL_TYPE_USER, + boolean_value=False, + ) + assert PrincipalAttributeAssignment._from_proto(proto).value is False + + +class TestPrincipalAttributeKeyUpdate: + def test_mask_includes_only_set_fields(self): + update = PrincipalAttributeKeyUpdate(description="new desc") + update.resource_id = "pk1" + + request, mask = update.to_proto_with_mask() + + assert list(mask.paths) == ["description"] + assert request.description == "new desc" + assert request.principal_attribute_key_id == "pk1" + + +class TestPrincipalAttributeAssignmentValuePropertyEnumDetails: + def test_value_returns_enum_value_object_when_details_present(self): + proto = pa.PrincipalAttributeValue( + principal_attribute_value_id="v1", + principal_attribute_key_id="pk1", + principal_id="u1", + principal_type=pa.PRINCIPAL_ATTRIBUTE_PRINCIPAL_TYPE_USER, + principal_attribute_enum_value_id="ev1", + enum_value_details=pa.PrincipalAttributeEnumValue( + principal_attribute_enum_value_id="ev1", + principal_attribute_key_id="pk1", + display_name="LICENSE_A", + ), + ) + value = PrincipalAttributeAssignment._from_proto(proto).value + assert isinstance(value, PrincipalAttributeEnumValue) + assert value.display_name == "LICENSE_A" + + def test_value_returns_number(self): + proto = pa.PrincipalAttributeValue( + principal_attribute_value_id="v1", + principal_attribute_key_id="pk1", + principal_id="u1", + principal_type=pa.PRINCIPAL_ATTRIBUTE_PRINCIPAL_TYPE_USER, + number_value=5, + ) + assert PrincipalAttributeAssignment._from_proto(proto).value == 5 diff --git a/python/lib/sift_client/_tests/sift_types/test_resource_attribute.py b/python/lib/sift_client/_tests/sift_types/test_resource_attribute.py index 5a7470816..ef9bb2f65 100644 --- a/python/lib/sift_client/_tests/sift_types/test_resource_attribute.py +++ b/python/lib/sift_client/_tests/sift_types/test_resource_attribute.py @@ -6,12 +6,13 @@ from sift.resource_attribute.v1 import resource_attribute_pb2 as ra from sift_client.sift_types.resource_attribute import ( - ResourceAttribute, + ResourceAttributeAssignment, ResourceAttributeEntity, ResourceAttributeEntityType, ResourceAttributeEnumValue, ResourceAttributeKey, - ResourceAttributeKeyType, + ResourceAttributeKeyUpdate, + ResourceAttributeValueType, ) @@ -47,7 +48,7 @@ def test_from_proto_maps_fields_and_renames_type(self): key = ResourceAttributeKey._from_proto(_key_proto()) assert key.id_ == "k1" assert key.display_name == "licenses" - assert key.key_type == ResourceAttributeKeyType.SET_OF_ENUM + assert key.value_type == ResourceAttributeValueType.SET_OF_ENUM assert key.is_archived is False assert key.archived_date is None @@ -62,7 +63,7 @@ def test_str_is_display_name(self): assert str(ResourceAttributeKey._from_proto(_key_proto())) == "licenses" -class TestResourceAttribute: +class TestResourceAttributeAssignment: def test_from_proto_flattens_enum_value_oneof(self): proto = ra.ResourceAttribute( resource_attribute_id="a1", @@ -72,7 +73,7 @@ def test_from_proto_flattens_enum_value_oneof(self): entity_id="ch1", entity_type=ra.RESOURCE_ATTRIBUTE_ENTITY_TYPE_CHANNEL ), ) - attr = ResourceAttribute._from_proto(proto) + attr = ResourceAttributeAssignment._from_proto(proto) assert attr.enum_value_id == "ev1" assert attr.boolean_value is None assert attr.number_value is None @@ -83,13 +84,13 @@ def test_from_proto_flattens_boolean_oneof(self): proto = ra.ResourceAttribute( resource_attribute_id="a1", resource_attribute_key_id="k1", boolean_value=True ) - attr = ResourceAttribute._from_proto(proto) + attr = ResourceAttributeAssignment._from_proto(proto) assert attr.boolean_value is True assert attr.enum_value_id is None def test_from_proto_without_entity_is_none(self): proto = ra.ResourceAttribute(resource_attribute_id="a1", resource_attribute_key_id="k1") - attr = ResourceAttribute._from_proto(proto) + attr = ResourceAttributeAssignment._from_proto(proto) assert attr.entity is None def test_apply_client_cascades_to_nested_key_and_enum_value(self, mock_client): @@ -102,7 +103,7 @@ def test_apply_client_cascades_to_nested_key_and_enum_value(self, mock_client): resource_attribute_enum_value_id="ev1", display_name="LIC_A" ), ) - attr = ResourceAttribute._from_proto(proto) + attr = ResourceAttributeAssignment._from_proto(proto) attr._apply_client_to_instance(mock_client) # Nested objects must also carry the client so their convenience methods work. @@ -113,13 +114,13 @@ def test_apply_client_cascades_to_nested_key_and_enum_value(self, mock_client): def test_archive_refreshes_and_returns_self(self, mock_client): proto = ra.ResourceAttribute(resource_attribute_id="a1", resource_attribute_key_id="k1") - attr = ResourceAttribute._from_proto(proto) + attr = ResourceAttributeAssignment._from_proto(proto) attr._apply_client_to_instance(mock_client) archived = ra.ResourceAttribute( resource_attribute_id="a1", resource_attribute_key_id="k1", is_archived=True ) mock_client.access_control.resource_attributes.get_assignment.return_value = ( - ResourceAttribute._from_proto(archived) + ResourceAttributeAssignment._from_proto(archived) ) result = attr.archive() @@ -186,3 +187,56 @@ def test_archive_returns_migrated_count(self, mock_client): value, replacement="ev2" ) assert migrated == 3 + + +class TestResourceAttributeAssignmentValueProperty: + def test_value_returns_enum_value_id_when_details_missing(self): + proto = ra.ResourceAttribute( + resource_attribute_id="a1", + resource_attribute_key_id="k1", + resource_attribute_enum_value_id="ev1", + ) + assert ResourceAttributeAssignment._from_proto(proto).value == "ev1" + + def test_value_returns_enum_value_object_when_details_present(self): + proto = ra.ResourceAttribute( + resource_attribute_id="a1", + resource_attribute_key_id="k1", + resource_attribute_enum_value_id="ev1", + enum_value_details=ra.ResourceAttributeEnumValue( + resource_attribute_enum_value_id="ev1", + resource_attribute_key_id="k1", + display_name="LICENSE_A", + ), + ) + value = ResourceAttributeAssignment._from_proto(proto).value + assert isinstance(value, ResourceAttributeEnumValue) + assert value.display_name == "LICENSE_A" + + def test_value_preserves_false_boolean(self): + proto = ra.ResourceAttribute( + resource_attribute_id="a1", resource_attribute_key_id="k1", boolean_value=False + ) + assert ResourceAttributeAssignment._from_proto(proto).value is False + + def test_value_returns_number(self): + proto = ra.ResourceAttribute( + resource_attribute_id="a1", resource_attribute_key_id="k1", number_value=5 + ) + assert ResourceAttributeAssignment._from_proto(proto).value == 5 + + +class TestResourceAttributeKeyUpdate: + def test_mask_includes_only_set_fields(self): + update = ResourceAttributeKeyUpdate(display_name="new name") + update.resource_id = "k1" + + request, mask = update.to_proto_with_mask() + + assert list(mask.paths) == ["display_name"] + assert request.display_name == "new name" + assert request.resource_attribute_key_id == "k1" + + def test_resource_id_required(self): + with pytest.raises(ValueError, match="Resource ID"): + ResourceAttributeKeyUpdate(display_name="x").to_proto_with_mask() diff --git a/python/lib/sift_client/_tests/sift_types/test_user.py b/python/lib/sift_client/_tests/sift_types/test_user.py new file mode 100644 index 000000000..327ecdcfb --- /dev/null +++ b/python/lib/sift_client/_tests/sift_types/test_user.py @@ -0,0 +1,47 @@ +"""Tests for the sift_types user model.""" + +from sift.common.type.v1.organization_pb2 import Organization +from sift.common.type.v1.user_pb2 import User as UserProto + +from sift_client.sift_types.user import User + + +def _user_proto() -> UserProto: + return UserProto( + user_id="u1", + user_name="alice@example.com", + organizations=[ + Organization(organization_id="org1", organization_name="Acme", is_abac_enabled=True) + ], + ) + + +class TestUser: + def test_from_proto_maps_id_and_name(self): + user = User._from_proto(_user_proto()) + + assert user.id_ == "u1" + assert user.name == "alice@example.com" + + def test_from_proto_maps_organizations(self): + user = User._from_proto(_user_proto()) + + assert len(user.organizations) == 1 + org = user.organizations[0] + assert org.organization_id == "org1" + assert org.organization_name == "Acme" + assert org.is_abac_enabled is True + + def test_from_proto_unset_abac_flag_is_none(self): + proto = UserProto( + user_id="u1", + user_name="alice@example.com", + organizations=[Organization(organization_id="org1", organization_name="Acme")], + ) + + user = User._from_proto(proto) + + assert user.organizations[0].is_abac_enabled is None + + def test_str_is_name(self): + assert str(User._from_proto(_user_proto())) == "alice@example.com" diff --git a/python/lib/sift_client/_tests/util/test_cel_utils.py b/python/lib/sift_client/_tests/util/test_cel_utils.py index 1ba1ccbe5..3ebf5f7f3 100644 --- a/python/lib/sift_client/_tests/util/test_cel_utils.py +++ b/python/lib/sift_client/_tests/util/test_cel_utils.py @@ -177,3 +177,20 @@ def test_less_than_duration(self): duration = timedelta(hours=1, minutes=15, seconds=30) expected_seconds = duration.total_seconds() assert less_than("field", duration) == f"field < duration('{expected_seconds}s')" + + +class TestStringQuoteEscaping: + def test_equals_escapes_single_quote(self): + assert equals("name", "o'brien@x.com") == "name == 'o\\'brien@x.com'" + + def test_in_escapes_single_quote(self): + assert in_("name", ["o'brien@x.com"]) == "name in ['o\\'brien@x.com']" + + def test_contains_escapes_single_quote(self): + assert contains("name", "o'brien") == "name.contains('o\\'brien')" + + def test_equals_escapes_backslash(self): + assert equals("name", "a\\b") == "name == 'a\\\\b'" + + def test_match_still_double_escapes_backslashes(self): + assert match("name", r"sensor_\d+") == "name.matches('sensor_\\\\d+')" diff --git a/python/lib/sift_client/client.py b/python/lib/sift_client/client.py index 4d63885c5..b7c68b7a2 100644 --- a/python/lib/sift_client/client.py +++ b/python/lib/sift_client/client.py @@ -39,6 +39,8 @@ TagsAPIAsync, TestResultsAPI, TestResultsAPIAsync, + UsersAPI, + UsersAPIAsync, ) from sift_client.resources.access_control import AccessControlAPI, AccessControlAPIAsync from sift_client.transport import ( @@ -130,6 +132,9 @@ class SiftClient( test_results: TestResultsAPI """Instance of the Test Results API for making synchronous requests.""" + users: UsersAPI + """Instance of the Users API for making synchronous requests.""" + data_export: DataExportAPI """Instance of the Data Export API for making synchronous requests.""" @@ -216,6 +221,7 @@ def __init__( ) self.tags = TagsAPI(self) self.test_results = TestResultsAPI(self) + self.users = UsersAPI(self) self.data_export = DataExportAPI(self) self.data_import = DataImportAPI(self) @@ -237,6 +243,7 @@ def __init__( ), tags=TagsAPIAsync(self), test_results=TestResultsAPIAsync(self), + users=UsersAPIAsync(self), data_export=DataExportAPIAsync(self), data_import=DataImportAPIAsync(self), ) diff --git a/python/lib/sift_client/resources/__init__.py b/python/lib/sift_client/resources/__init__.py index bc24f338d..1b1655213 100644 --- a/python/lib/sift_client/resources/__init__.py +++ b/python/lib/sift_client/resources/__init__.py @@ -170,6 +170,7 @@ async def main(): from sift_client.resources.runs import RunsAPIAsync from sift_client.resources.tags import TagsAPIAsync from sift_client.resources.test_results import TestResultsAPIAsync +from sift_client.resources.users import UsersAPIAsync from sift_client.resources.data_imports import DataImportAPIAsync from sift_client.resources.exports import DataExportAPIAsync @@ -188,6 +189,7 @@ async def main(): RunsAPI, TagsAPI, TestResultsAPI, + UsersAPI, FileAttachmentsAPI, DataExportAPI, DataImportAPI, @@ -235,6 +237,8 @@ async def main(): "TestResultsAPI", "TestResultsAPIAsync", "TracingConfig", + "UsersAPI", + "UsersAPIAsync", "DataExportAPI", "DataExportAPIAsync", "DataImportAPI", diff --git a/python/lib/sift_client/resources/_base.py b/python/lib/sift_client/resources/_base.py index 33d7e2659..ab1959cc3 100644 --- a/python/lib/sift_client/resources/_base.py +++ b/python/lib/sift_client/resources/_base.py @@ -53,16 +53,17 @@ def _build_name_cel_filters( names: list[str] | None = None, name_contains: str | None = None, name_regex: str | re.Pattern | None = None, + field: str = "name", ) -> list[str]: filter_parts = [] if name: - filter_parts.append(cel.equals("name", name)) + filter_parts.append(cel.equals(field, name)) if names: - filter_parts.append(cel.in_("name", names)) + filter_parts.append(cel.in_(field, names)) if name_contains: - filter_parts.append(cel.contains("name", name_contains)) + filter_parts.append(cel.contains(field, name_contains)) if name_regex: - filter_parts.append(cel.match("name", name_regex)) + filter_parts.append(cel.match(field, name_regex)) return filter_parts def _build_time_cel_filters( @@ -90,7 +91,7 @@ def _build_time_cel_filters( raise NotImplementedError if modified_by: if isinstance(modified_by, str): - filter_parts.append(cel.equals("modified_by_user_id", created_by)) + filter_parts.append(cel.equals("modified_by_user_id", modified_by)) else: raise NotImplementedError return filter_parts diff --git a/python/lib/sift_client/resources/access_control/_common.py b/python/lib/sift_client/resources/access_control/_common.py new file mode 100644 index 000000000..56e5c9fa2 --- /dev/null +++ b/python/lib/sift_client/resources/access_control/_common.py @@ -0,0 +1,62 @@ +"""Helpers shared by the resource- and principal-attribute resources. + +Both sides expose the same three-tier primitive (key, enum value, assignment), so the +ID extraction, key resolution, and value-to-kwargs mapping are identical modulo types. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any, TypeVar + +from sift_client.sift_types._base import BaseType + +if TYPE_CHECKING: + from collections.abc import Awaitable, Callable + from enum import Enum + +KeyT = TypeVar("KeyT", bound=BaseType) + + +def id_of(value: BaseType | str) -> str: + """Return the ID of a sift type instance, or the value itself if already an ID.""" + return value._id_or_error if isinstance(value, BaseType) else value + + +async def resolve_key( + key: KeyT | str, *, key_cls: type[KeyT], getter: Callable[[str], Awaitable[KeyT]] +) -> KeyT: + """Resolve a key or key ID to a full key instance, fetching it when given an ID.""" + if isinstance(key, key_cls): + return key + if not isinstance(key, str): + raise TypeError(f"assign requires a {key_cls.__name__} or key ID string.") + if not key: + raise ValueError("Key ID cannot be empty.") + return await getter(key) + + +def attribute_value_kwargs(value_type: Enum, value: Any) -> dict[str, Any]: + """Map a user-supplied value to the batch-create value kwargs for a key's value type. + + Dispatches on the enum member name, which is identical for the resource and + principal value-type enums (ENUM, BOOLEAN, NUMBER, SET_OF_ENUM). + """ + if value_type.name == "SET_OF_ENUM": + if not isinstance(value, (list, tuple)): + raise TypeError("SET_OF_ENUM keys require a list of enum values.") + return {"enum_value_ids": [id_of(v) for v in value]} + if value_type.name == "ENUM": + if isinstance(value, (list, tuple)): + if len(value) != 1: + raise ValueError("ENUM keys require exactly one enum value.") + value = value[0] + return {"enum_value_id": id_of(value)} + if value_type.name == "BOOLEAN": + if not isinstance(value, bool): + raise TypeError("BOOLEAN keys require a bool value.") + return {"boolean_value": value} + if value_type.name == "NUMBER": + if isinstance(value, bool) or not isinstance(value, int): + raise TypeError("NUMBER keys require an int value.") + return {"number_value": value} + raise ValueError(f"Cannot assign a value for value type {value_type}.") diff --git a/python/lib/sift_client/resources/access_control/principal_attributes.py b/python/lib/sift_client/resources/access_control/principal_attributes.py index c225e3c46..2caebf201 100644 --- a/python/lib/sift_client/resources/access_control/principal_attributes.py +++ b/python/lib/sift_client/resources/access_control/principal_attributes.py @@ -1,16 +1,22 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING from sift_client._internal.low_level_wrappers.principal_attributes import ( PrincipalAttributesLowLevelClient, ) -from sift_client._internal.util.executor import run_sync_function from sift_client.resources._base import ResourceBase +from sift_client.resources.access_control._common import ( + attribute_value_kwargs, + id_of, + resolve_key, +) from sift_client.sift_types.principal_attribute import ( + PrincipalAttributeAssignment, PrincipalAttributeEnumValue, PrincipalAttributeKey, - PrincipalAttributeValue, + PrincipalAttributeKeyUpdate, + PrincipalAttributeValueLike, PrincipalAttributeValueType, PrincipalType, ) @@ -21,28 +27,6 @@ from sift_client.client import SiftClient -# Max principals per BatchCreatePrincipalAttributeValue call. -ASSIGN_BATCH_SIZE = 1000 -# REST endpoint used to resolve a user's email (its user_name) to a user ID. The gRPC -# UserService does not expose email, so resolution goes through REST. -_USERS_ENDPOINT = "/api/v2/users" -_USERS_PAGE_SIZE = 1000 - - -def _enum_value_id(value: PrincipalAttributeEnumValue | str) -> str: - return value._id_or_error if isinstance(value, PrincipalAttributeEnumValue) else value - - -def _assignment_id(assignment: PrincipalAttributeValue | str) -> str: - return ( - assignment._id_or_error if isinstance(assignment, PrincipalAttributeValue) else assignment - ) - - -def _chunks(items: list[Any], size: int): - for i in range(0, len(items), size): - yield items[i : i + size] - class PrincipalAttributesAPIAsync(ResourceBase): """High-level API for principal attributes. @@ -66,15 +50,6 @@ def __init__(self, sift_client: SiftClient): grpc_client=self.client.grpc_client ) - async def _resolve_key(self, key: str | PrincipalAttributeKey) -> PrincipalAttributeKey: - if isinstance(key, PrincipalAttributeKey): - return key - if not isinstance(key, str): - raise TypeError("assign requires a PrincipalAttributeKey or key ID string.") - if not key: - raise ValueError("Key ID cannot be empty.") - return await self.get_key(key_id=key) - async def get_key(self, *, key_id: str) -> PrincipalAttributeKey: """Get a principal attribute key by ID.""" key = await self._low_level_client.get_key(key_id) @@ -109,8 +84,12 @@ async def list_keys( page_size: Results to fetch per request. """ # The key list filter exposes the display name as the CEL field `display_name`. - filter_parts = _build_display_name_filters( - name=name, names=names, name_contains=name_contains, name_regex=name_regex + filter_parts = self._build_name_cel_filters( + name=name, + names=names, + name_contains=name_contains, + name_regex=name_regex, + field="display_name", ) if value_type is not None: filter_parts.append(cel.equals("value_type", value_type.value)) @@ -168,26 +147,29 @@ async def get_or_create_key( async def update_key( self, key: str | PrincipalAttributeKey, - *, - display_name: str | None = None, - description: str | None = None, + update: PrincipalAttributeKeyUpdate | dict, ) -> PrincipalAttributeKey: - """Update a key's display name or description.""" - key_id = key._id_or_error if isinstance(key, PrincipalAttributeKey) else key - updated = await self._low_level_client.update_key( - key_id, display_name=display_name, description=description - ) + """Update a key. + + Args: + key: The key or key ID to update. + update: Updates to apply to the key. + """ + if isinstance(update, dict): + update = PrincipalAttributeKeyUpdate.model_validate(update) + update.resource_id = id_of(key) + updated = await self._low_level_client.update_key(update=update) return self._apply_client_to_instance(updated) async def archive_key(self, key: str | PrincipalAttributeKey) -> PrincipalAttributeKey: """Archive a key. Cascades to its enum values and assignments.""" - key_id = key._id_or_error if isinstance(key, PrincipalAttributeKey) else key + key_id = id_of(key) await self._low_level_client.archive_key(key_id) return await self.get_key(key_id=key_id) async def unarchive_key(self, key: str | PrincipalAttributeKey) -> PrincipalAttributeKey: """Unarchive a key. Does not restore its cascaded enum values or assignments.""" - key_id = key._id_or_error if isinstance(key, PrincipalAttributeKey) else key + key_id = id_of(key) await self._low_level_client.unarchive_key(key_id) return await self.get_key(key_id=key_id) @@ -196,8 +178,7 @@ async def check_key_archive_impact(self, key: str | PrincipalAttributeKey) -> in Counts both user and user-group assignments. """ - key_id = key._id_or_error if isinstance(key, PrincipalAttributeKey) else key - return await self._low_level_client.check_key_archive_impact(key_id) + return await self._low_level_client.check_key_archive_impact(id_of(key)) async def create_enum_value( self, @@ -207,7 +188,7 @@ async def create_enum_value( description: str = "", ) -> PrincipalAttributeEnumValue: """Create a single enum value for a key.""" - key_id = key._id_or_error if isinstance(key, PrincipalAttributeKey) else key + key_id = id_of(key) value = await self._low_level_client.create_enum_value( key_id=key_id, display_name=display_name, description=description ) @@ -228,7 +209,7 @@ async def list_enum_values( page_size: int | None = None, ) -> list[PrincipalAttributeEnumValue]: """List the enum values defined for a key.""" - key_id = key._id_or_error if isinstance(key, PrincipalAttributeKey) else key + key_id = id_of(key) filter_parts = self._build_name_cel_filters( name=name, names=names, name_contains=name_contains, name_regex=name_regex ) @@ -251,7 +232,7 @@ async def get_or_create_enum_values( Returns the values in the same order as ``names``. """ - key_id = key._id_or_error if isinstance(key, PrincipalAttributeKey) else key + key_id = id_of(key) existing = await self.list_enum_values(key_id, include_archived=False) by_name = {v.display_name: v for v in existing} result: list[PrincipalAttributeEnumValue] = [] @@ -273,8 +254,8 @@ async def archive_enum_value( Returns the number of assignments migrated. """ - enum_value_id = _enum_value_id(enum_value) - replacement_id = _enum_value_id(replacement) if replacement is not None else "" + enum_value_id = id_of(enum_value) + replacement_id = id_of(replacement) if replacement is not None else "" return await self._low_level_client.archive_enum_value( enum_value_id, replacement_enum_value_id=replacement_id ) @@ -283,7 +264,7 @@ async def unarchive_enum_value( self, enum_value: str | PrincipalAttributeEnumValue ) -> PrincipalAttributeEnumValue: """Unarchive an enum value.""" - enum_value_id = _enum_value_id(enum_value) + enum_value_id = id_of(enum_value) await self._low_level_client.unarchive_enum_value(enum_value_id) value = await self._low_level_client.get_enum_value(enum_value_id) return self._apply_client_to_instance(value) @@ -293,9 +274,9 @@ async def assign( key: str | PrincipalAttributeKey, principals: list[str], *, - value: Any, + value: PrincipalAttributeValueLike, principal_type: PrincipalType = PrincipalType.USER, - ) -> list[PrincipalAttributeValue]: + ) -> list[PrincipalAttributeAssignment]: """Assign a key's value to principals. Args: @@ -311,19 +292,18 @@ async def assign( Returns: The created assignments. """ - resolved_key = await self._resolve_key(key) + resolved_key = await resolve_key( + key, key_cls=PrincipalAttributeKey, getter=lambda key_id: self.get_key(key_id=key_id) + ) resolved_ids = await self._resolve_principal_ids(principals, principal_type=principal_type) - create_kwargs = _principal_value_kwargs(resolved_key.value_type, value) + create_kwargs = attribute_value_kwargs(resolved_key.value_type, value) - created: list[PrincipalAttributeValue] = [] - for batch in _chunks(resolved_ids, ASSIGN_BATCH_SIZE): - values = await self._low_level_client.batch_create_values( - key_id=resolved_key._id_or_error, - principal_ids=batch, - principal_type=principal_type.value, - **create_kwargs, - ) - created.extend(values) + created = await self._low_level_client.batch_create_values( + key_id=resolved_key._id_or_error, + principal_ids=resolved_ids, + principal_type=principal_type.value, + **create_kwargs, + ) return self._apply_client_to_instances(created) async def get_assignment( @@ -331,7 +311,7 @@ async def get_assignment( *, assignment_id: str, principal_type: PrincipalType = PrincipalType.USER, - ) -> PrincipalAttributeValue: + ) -> PrincipalAttributeAssignment: """Get a single assignment by ID.""" value = await self._low_level_client.get_value( assignment_id, principal_type=principal_type.value @@ -349,7 +329,7 @@ async def list_assignments( order_by: str | None = None, limit: int | None = None, page_size: int | None = None, - ) -> list[PrincipalAttributeValue]: + ) -> list[PrincipalAttributeAssignment]: """List principal attribute assignments. Args: @@ -374,9 +354,8 @@ async def list_assignments( query_filter = cel.and_(*filter_parts) or None if key is not None: - key_id = key._id_or_error if isinstance(key, PrincipalAttributeKey) else key values = await self._low_level_client.list_all_key_values( - key_id=key_id, + key_id=id_of(key), principal_type=principal_type.value, query_filter=query_filter, order_by=order_by, @@ -397,53 +376,23 @@ async def list_assignments( async def archive_assignments( self, - assignments: list[str | PrincipalAttributeValue], + assignments: list[str | PrincipalAttributeAssignment], *, principal_type: PrincipalType = PrincipalType.USER, ) -> None: """Batch archive assignments.""" - ids = [_assignment_id(a) for a in assignments] - for batch in _chunks(ids, ASSIGN_BATCH_SIZE): - await self._low_level_client.archive_values(batch, principal_type=principal_type.value) + ids = [id_of(a) for a in assignments] + await self._low_level_client.archive_values(ids, principal_type=principal_type.value) async def unarchive_assignments( self, - assignments: list[str | PrincipalAttributeValue], + assignments: list[str | PrincipalAttributeAssignment], *, principal_type: PrincipalType = PrincipalType.USER, ) -> None: """Batch unarchive assignments.""" - ids = [_assignment_id(a) for a in assignments] - for batch in _chunks(ids, ASSIGN_BATCH_SIZE): - await self._low_level_client.unarchive_values( - batch, principal_type=principal_type.value - ) - - async def resolve_user_id(self, email: str) -> str: - """Resolve a user's email (its user name) to a user ID. - - Raises: - ValueError: If no user with that email is found. - """ - resolved = await self.resolve_user_ids([email]) - if email not in resolved: - raise ValueError(f"No user found for email {email!r}") - return resolved[email] - - async def resolve_user_ids(self, emails: list[str]) -> dict[str, str]: - """Resolve user emails (their user names) to user IDs. - - Returns a mapping of email to user ID for the emails that were found. Emails with - no matching user are omitted. - """ - wanted = set(emails) - if not wanted: - return {} - return { - user_name: user_id - for user_name, user_id in await self._list_users() - if user_name in wanted - } + ids = [id_of(a) for a in assignments] + await self._low_level_client.unarchive_values(ids, principal_type=principal_type.value) async def _resolve_principal_ids( self, principals: list[str], *, principal_type: PrincipalType @@ -454,7 +403,7 @@ async def _resolve_principal_ids( for p in principals if principal_type == PrincipalType.USER and isinstance(p, str) and "@" in p ] - email_to_id = await self.resolve_user_ids(emails) if emails else {} + email_to_id = await self.client.async_.users.resolve_ids(emails) if emails else {} resolved: list[str] = [] for principal in principals: if principal in email_to_id: @@ -469,66 +418,3 @@ async def _resolve_principal_ids( else: resolved.append(principal) return resolved - - async def _list_users(self) -> list[tuple[str, str]]: - """Return (user_name, user_id) for all users, via the REST users endpoint.""" - rest = self.rest_client - users: list[tuple[str, str]] = [] - page_token = "" - while True: - params: dict[str, Any] = {"page_size": _USERS_PAGE_SIZE} - if page_token: - params["pageToken"] = page_token - response = await run_sync_function(lambda p=params: rest.get(_USERS_ENDPOINT, params=p)) - response.raise_for_status() - data = response.json() - users.extend( - (user.get("userName", ""), user.get("userId", "")) for user in data.get("users", []) - ) - page_token = data.get("nextPageToken", "") - if not page_token: - break - return users - - -def _build_display_name_filters( - *, - name: str | None = None, - names: list[str] | None = None, - name_contains: str | None = None, - name_regex: Any | None = None, -) -> list[str]: - """Build CEL filters against the `display_name` field (used by principal keys).""" - filter_parts = [] - if name: - filter_parts.append(cel.equals("display_name", name)) - if names: - filter_parts.append(cel.in_("display_name", names)) - if name_contains: - filter_parts.append(cel.contains("display_name", name_contains)) - if name_regex: - filter_parts.append(cel.match("display_name", name_regex)) - return filter_parts - - -def _principal_value_kwargs(value_type: PrincipalAttributeValueType, value: Any) -> dict[str, Any]: - """Map a user-supplied value to the BatchCreatePrincipalAttributeValue value kwargs.""" - if value_type == PrincipalAttributeValueType.SET_OF_ENUM: - if not isinstance(value, (list, tuple)): - raise TypeError("SET_OF_ENUM keys require a list of enum values.") - return {"enum_value_ids": [_enum_value_id(v) for v in value]} - if value_type == PrincipalAttributeValueType.ENUM: - if isinstance(value, (list, tuple)): - if len(value) != 1: - raise ValueError("ENUM keys require exactly one enum value.") - value = value[0] - return {"enum_value_id": _enum_value_id(value)} - if value_type == PrincipalAttributeValueType.BOOLEAN: - if not isinstance(value, bool): - raise TypeError("BOOLEAN keys require a bool value.") - return {"boolean_value": value} - if value_type == PrincipalAttributeValueType.NUMBER: - if isinstance(value, bool) or not isinstance(value, int): - raise TypeError("NUMBER keys require an int value.") - return {"number_value": value} - raise ValueError(f"Cannot assign a value for value type {value_type}.") diff --git a/python/lib/sift_client/resources/access_control/resource_attributes.py b/python/lib/sift_client/resources/access_control/resource_attributes.py index b923f8442..2e8d0f3c2 100644 --- a/python/lib/sift_client/resources/access_control/resource_attributes.py +++ b/python/lib/sift_client/resources/access_control/resource_attributes.py @@ -1,21 +1,29 @@ from __future__ import annotations import asyncio -from typing import TYPE_CHECKING, Any, Union +from typing import TYPE_CHECKING, Union from sift_client._internal.low_level_wrappers.resource_attributes import ( ResourceAttributesLowLevelClient, ) +from sift_client._internal.util.util import chunked from sift_client.resources._base import ResourceBase +from sift_client.resources.access_control._common import ( + attribute_value_kwargs, + id_of, + resolve_key, +) from sift_client.sift_types.asset import Asset from sift_client.sift_types.channel import Channel from sift_client.sift_types.resource_attribute import ( - ResourceAttribute, + ResourceAttributeAssignment, ResourceAttributeEntity, ResourceAttributeEntityType, ResourceAttributeEnumValue, ResourceAttributeKey, - ResourceAttributeKeyType, + ResourceAttributeKeyUpdate, + ResourceAttributeValueLike, + ResourceAttributeValueType, ) from sift_client.sift_types.run import Run from sift_client.util import cel_utils as cel @@ -25,9 +33,9 @@ from sift_client.client import SiftClient -# Max resources per BatchCreateResourceAttributes call; keeps request bodies well under -# gRPC message size limits when assigning to large resource sets. -ASSIGN_BATCH_SIZE = 1000 +# Chunk size for the ID-resolution lookups that fan out to the assets/channels/runs +# list APIs. +_RESOLVE_BATCH_SIZE = 1000 ResourceLike = Union[ResourceAttributeEntity, Asset, Channel, Run, str] SUPPORTED_RESOURCE_ENTITY_TYPES = { @@ -56,15 +64,6 @@ def _resolve_resource_object(resource: ResourceLike) -> ResourceAttributeEntity: ) -def _enum_value_id(value: ResourceAttributeEnumValue | str) -> str: - return value._id_or_error if isinstance(value, ResourceAttributeEnumValue) else value - - -def _chunks(items: list[Any], size: int): - for i in range(0, len(items), size): - yield items[i : i + size] - - class ResourceAttributesAPIAsync(ResourceBase): """High-level API for resource attributes. @@ -87,15 +86,6 @@ def __init__(self, sift_client: SiftClient): grpc_client=self.client.grpc_client ) - async def _resolve_key(self, key: str | ResourceAttributeKey) -> ResourceAttributeKey: - if isinstance(key, ResourceAttributeKey): - return key - if not isinstance(key, str): - raise TypeError("assign requires a ResourceAttributeKey or key ID string.") - if not key: - raise ValueError("Key ID cannot be empty.") - return await self.get_key(key_id=key) - async def _resolve_resource(self, resource: ResourceLike) -> ResourceAttributeEntity: return (await self._resolve_resources([resource]))[0] @@ -129,7 +119,7 @@ async def _resolve_resource_ids( ) -> dict[str, ResourceAttributeEntity]: unique_ids = list(dict.fromkeys(resource_ids)) matched_types: dict[str, set[ResourceAttributeEntityType]] = {} - for batch in _chunks(unique_ids, ASSIGN_BATCH_SIZE): + for batch in chunked(unique_ids, _RESOLVE_BATCH_SIZE): assets: list[Asset] channels: list[Channel] runs: list[Run] @@ -193,7 +183,7 @@ async def list_keys( names: list[str] | None = None, name_contains: str | None = None, name_regex: str | re.Pattern | None = None, - key_type: ResourceAttributeKeyType | None = None, + value_type: ResourceAttributeValueType | None = None, include_archived: bool = False, filter_query: str | None = None, order_by: str | None = None, @@ -207,7 +197,7 @@ async def list_keys( names: Display names to filter by. name_contains: Substring match on the display name. name_regex: Regex match on the display name. - key_type: Filter to keys of this value type. + value_type: Filter to keys of this value type. include_archived: If True, include archived keys. filter_query: Explicit CEL query. order_by: Field and direction to order by. @@ -217,12 +207,13 @@ async def list_keys( Returns: The matching keys. """ - # The key list filter exposes the display name as the CEL field `name`. + # The key list filter exposes the display name as the CEL field `name` and the + # value type as `key_type`. filter_parts = self._build_name_cel_filters( name=name, names=names, name_contains=name_contains, name_regex=name_regex ) - if key_type is not None: - filter_parts.append(cel.equals("key_type", key_type.value)) + if value_type is not None: + filter_parts.append(cel.equals("key_type", value_type.value)) if filter_query: filter_parts.append(filter_query) @@ -245,7 +236,7 @@ async def find_key(self, **kwargs) -> ResourceAttributeKey | None: async def create_key( self, display_name: str, - key_type: ResourceAttributeKeyType, + value_type: ResourceAttributeValueType, *, description: str = "", ) -> ResourceAttributeKey: @@ -253,21 +244,21 @@ async def create_key( Args: display_name: The human-readable name of the key. - key_type: The value type of the key. + value_type: The value type of the key. description: Optional description. Returns: The created key. """ key = await self._low_level_client.create_key( - display_name=display_name, key_type=key_type.value, description=description + display_name=display_name, value_type=value_type.value, description=description ) return self._apply_client_to_instance(key) async def get_or_create_key( self, display_name: str, - key_type: ResourceAttributeKeyType, + value_type: ResourceAttributeValueType, *, description: str = "", ) -> ResourceAttributeKey: @@ -281,38 +272,40 @@ async def get_or_create_key( match = next((k for k in existing if k.display_name == display_name), None) if match is not None: return match - return await self.create_key(display_name, key_type, description=description) + return await self.create_key(display_name, value_type, description=description) async def update_key( self, key: str | ResourceAttributeKey, - *, - display_name: str | None = None, - description: str | None = None, + update: ResourceAttributeKeyUpdate | dict, ) -> ResourceAttributeKey: - """Update a key's display name or description.""" - key_id = key._id_or_error if isinstance(key, ResourceAttributeKey) else key - updated = await self._low_level_client.update_key( - key_id, display_name=display_name, description=description - ) + """Update a key. + + Args: + key: The key or key ID to update. + update: Updates to apply to the key. + """ + if isinstance(update, dict): + update = ResourceAttributeKeyUpdate.model_validate(update) + update.resource_id = id_of(key) + updated = await self._low_level_client.update_key(update=update) return self._apply_client_to_instance(updated) async def archive_key(self, key: str | ResourceAttributeKey) -> ResourceAttributeKey: """Archive a key. Cascades to its enum values and assignments.""" - key_id = key._id_or_error if isinstance(key, ResourceAttributeKey) else key + key_id = id_of(key) await self._low_level_client.archive_key(key_id) return await self.get_key(key_id=key_id) async def unarchive_key(self, key: str | ResourceAttributeKey) -> ResourceAttributeKey: """Unarchive a key. Does not restore its cascaded enum values or assignments.""" - key_id = key._id_or_error if isinstance(key, ResourceAttributeKey) else key + key_id = id_of(key) await self._low_level_client.unarchive_key(key_id) return await self.get_key(key_id=key_id) async def check_key_archive_impact(self, key: str | ResourceAttributeKey) -> int: """Return the number of active assignments archiving this key would affect.""" - key_id = key._id_or_error if isinstance(key, ResourceAttributeKey) else key - return await self._low_level_client.check_key_archive_impact(key_id) + return await self._low_level_client.check_key_archive_impact(id_of(key)) async def create_enum_value( self, @@ -322,7 +315,7 @@ async def create_enum_value( description: str = "", ) -> ResourceAttributeEnumValue: """Create a single enum value for a key.""" - key_id = key._id_or_error if isinstance(key, ResourceAttributeKey) else key + key_id = id_of(key) value = await self._low_level_client.create_enum_value( key_id=key_id, display_name=display_name, description=description ) @@ -343,7 +336,7 @@ async def list_enum_values( page_size: int | None = None, ) -> list[ResourceAttributeEnumValue]: """List the enum values defined for a key.""" - key_id = key._id_or_error if isinstance(key, ResourceAttributeKey) else key + key_id = id_of(key) filter_parts = self._build_name_cel_filters( name=name, names=names, name_contains=name_contains, name_regex=name_regex ) @@ -366,7 +359,7 @@ async def get_or_create_enum_values( Returns the values in the same order as ``names``. """ - key_id = key._id_or_error if isinstance(key, ResourceAttributeKey) else key + key_id = id_of(key) existing = await self.list_enum_values(key_id, include_archived=False) by_name = {v.display_name: v for v in existing} result: list[ResourceAttributeEnumValue] = [] @@ -388,8 +381,8 @@ async def archive_enum_value( Returns the number of assignments migrated. """ - enum_value_id = _enum_value_id(enum_value) - replacement_id = _enum_value_id(replacement) if replacement is not None else "" + enum_value_id = id_of(enum_value) + replacement_id = id_of(replacement) if replacement is not None else "" return await self._low_level_client.archive_enum_value( enum_value_id, replacement_enum_value_id=replacement_id ) @@ -398,7 +391,7 @@ async def unarchive_enum_value( self, enum_value: str | ResourceAttributeEnumValue ) -> ResourceAttributeEnumValue: """Unarchive an enum value.""" - enum_value_id = _enum_value_id(enum_value) + enum_value_id = id_of(enum_value) await self._low_level_client.unarchive_enum_value(enum_value_id) value = await self._low_level_client.get_enum_value(enum_value_id) return self._apply_client_to_instance(value) @@ -408,12 +401,12 @@ async def assign( key: str | ResourceAttributeKey, resources: list[ResourceAttributeEntity | Asset | Channel | Run | str], *, - value: Any, - ) -> list[ResourceAttribute]: + value: ResourceAttributeValueLike, + ) -> list[ResourceAttributeAssignment]: """Assign a key's value to resources. Args: - key: The key or key ID to assign. Its ``key_type`` determines how ``value`` is interpreted. + key: The key or key ID to assign. Its ``value_type`` determines how ``value`` is interpreted. resources: Resources to assign to. For currently supported resource types, pass ``Asset``, ``Channel``, or ``Run`` objects, their IDs, or ``ResourceAttributeEntity`` when you already know the resource type. @@ -424,19 +417,18 @@ async def assign( Returns: The created assignments. """ - resolved_key = await self._resolve_key(key) + resolved_key = await resolve_key( + key, key_cls=ResourceAttributeKey, getter=lambda key_id: self.get_key(key_id=key_id) + ) resolved = await self._resolve_resources(resources) - create_kwargs = _resource_value_kwargs(resolved_key.key_type, value) + create_kwargs = attribute_value_kwargs(resolved_key.value_type, value) - created: list[ResourceAttribute] = [] - for batch in _chunks(resolved, ASSIGN_BATCH_SIZE): - attrs = await self._low_level_client.batch_create_resource_attributes( - key_id=resolved_key._id_or_error, entities=batch, **create_kwargs - ) - created.extend(attrs) + created = await self._low_level_client.batch_create_resource_attributes( + key_id=resolved_key._id_or_error, entities=resolved, **create_kwargs + ) return self._apply_client_to_instances(created) - async def get_assignment(self, *, assignment_id: str) -> ResourceAttribute: + async def get_assignment(self, *, assignment_id: str) -> ResourceAttributeAssignment: """Get a single assignment by ID.""" attr = await self._low_level_client.get_resource_attribute(assignment_id) return self._apply_client_to_instance(attr) @@ -451,7 +443,7 @@ async def list_assignments( order_by: str | None = None, limit: int | None = None, page_size: int | None = None, - ) -> list[ResourceAttribute]: + ) -> list[ResourceAttributeAssignment]: """List resource attribute assignments. Args: @@ -476,8 +468,7 @@ async def list_assignments( filter_parts = [] if key is not None: - key_id = key._id_or_error if isinstance(key, ResourceAttributeKey) else key - filter_parts.append(cel.equals("resource_attribute_key_id", key_id)) + filter_parts.append(cel.equals("resource_attribute_key_id", id_of(key))) if filter_query: filter_parts.append(filter_query) @@ -490,41 +481,16 @@ async def list_assignments( ) return self._apply_client_to_instances(attrs) - async def archive_assignments(self, assignments: list[str | ResourceAttribute]) -> None: + async def archive_assignments( + self, assignments: list[str | ResourceAttributeAssignment] + ) -> None: """Batch archive assignments.""" - ids = [_assignment_id(a) for a in assignments] - for batch in _chunks(ids, ASSIGN_BATCH_SIZE): - await self._low_level_client.batch_archive_resource_attributes(batch) + ids = [id_of(a) for a in assignments] + await self._low_level_client.batch_archive_resource_attributes(ids) - async def unarchive_assignments(self, assignments: list[str | ResourceAttribute]) -> None: + async def unarchive_assignments( + self, assignments: list[str | ResourceAttributeAssignment] + ) -> None: """Batch unarchive assignments.""" - ids = [_assignment_id(a) for a in assignments] - for batch in _chunks(ids, ASSIGN_BATCH_SIZE): - await self._low_level_client.batch_unarchive_resource_attributes(batch) - - -def _assignment_id(assignment: str | ResourceAttribute) -> str: - return assignment._id_or_error if isinstance(assignment, ResourceAttribute) else assignment - - -def _resource_value_kwargs(key_type: ResourceAttributeKeyType, value: Any) -> dict[str, Any]: - """Map a user-supplied value to the BatchCreateResourceAttributes value kwargs.""" - if key_type == ResourceAttributeKeyType.SET_OF_ENUM: - if not isinstance(value, (list, tuple)): - raise TypeError("SET_OF_ENUM keys require a list of enum values.") - return {"enum_value_ids": [_enum_value_id(v) for v in value]} - if key_type == ResourceAttributeKeyType.ENUM: - if isinstance(value, (list, tuple)): - if len(value) != 1: - raise ValueError("ENUM keys require exactly one enum value.") - value = value[0] - return {"enum_value_id": _enum_value_id(value)} - if key_type == ResourceAttributeKeyType.BOOLEAN: - if not isinstance(value, bool): - raise TypeError("BOOLEAN keys require a bool value.") - return {"boolean_value": value} - if key_type == ResourceAttributeKeyType.NUMBER: - if isinstance(value, bool) or not isinstance(value, int): - raise TypeError("NUMBER keys require an int value.") - return {"number_value": value} - raise ValueError(f"Cannot assign a value for key type {key_type}.") + ids = [id_of(a) for a in assignments] + await self._low_level_client.batch_unarchive_resource_attributes(ids) diff --git a/python/lib/sift_client/resources/file_attachments.py b/python/lib/sift_client/resources/file_attachments.py index c067ffbeb..89084ac3f 100644 --- a/python/lib/sift_client/resources/file_attachments.py +++ b/python/lib/sift_client/resources/file_attachments.py @@ -41,26 +41,6 @@ def __init__(self, sift_client: SiftClient): self._low_level_client = RemoteFilesLowLevelClient(grpc_client=self.client.grpc_client) self._upload_client = UploadLowLevelClient(rest_client=self.client.rest_client) - def _build_name_cel_filters( - self, - *, - name: str | None = None, - names: list[str] | None = None, - name_contains: str | None = None, - name_regex: str | re.Pattern | None = None, - ) -> list[str]: - """Override base implementation to use 'file_name' field instead of 'name'.""" - filter_parts = [] - if name: - filter_parts.append(cel.equals("file_name", name)) - if names: - filter_parts.append(cel.in_("file_name", names)) - if name_contains: - filter_parts.append(cel.contains("file_name", name_contains)) - if name_regex: - filter_parts.append(cel.match("file_name", name_regex)) - return filter_parts - async def get(self, *, file_attachment_id: str) -> FileAttachment: """Get a file attachment by ID. @@ -128,8 +108,13 @@ async def list_( A list of FileAttachment objects that match the filter criteria. """ filter_parts = [ + # The file attachment list filter exposes the name as the CEL field `file_name`. *self._build_name_cel_filters( - name=name, names=names, name_contains=name_contains, name_regex=name_regex + name=name, + names=names, + name_contains=name_contains, + name_regex=name_regex, + field="file_name", ), # *self._build_time_cel_filters( # created_after=created_after, diff --git a/python/lib/sift_client/resources/sync_stubs/__init__.py b/python/lib/sift_client/resources/sync_stubs/__init__.py index 4406bd289..b80e93187 100644 --- a/python/lib/sift_client/resources/sync_stubs/__init__.py +++ b/python/lib/sift_client/resources/sync_stubs/__init__.py @@ -20,6 +20,7 @@ RunsAPIAsync, TagsAPIAsync, TestResultsAPIAsync, + UsersAPIAsync, ) PingAPI = generate_sync_api(PingAPIAsync, "PingAPI") @@ -39,6 +40,7 @@ PrincipalAttributesAPI = generate_sync_api(PrincipalAttributesAPIAsync, "PrincipalAttributesAPI") TagsAPI = generate_sync_api(TagsAPIAsync, "TagsAPI") TestResultsAPI = generate_sync_api(TestResultsAPIAsync, "TestResultsAPI") +UsersAPI = generate_sync_api(UsersAPIAsync, "UsersAPI") DataExportAPI = generate_sync_api(DataExportAPIAsync, "DataExportAPI") DataImportAPI = generate_sync_api(DataImportAPIAsync, "DataImportAPI") @@ -59,4 +61,5 @@ "RunsAPI", "TagsAPI", "TestResultsAPI", + "UsersAPI", ] diff --git a/python/lib/sift_client/resources/sync_stubs/__init__.pyi b/python/lib/sift_client/resources/sync_stubs/__init__.pyi index 815dd8528..1130039a9 100644 --- a/python/lib/sift_client/resources/sync_stubs/__init__.pyi +++ b/python/lib/sift_client/resources/sync_stubs/__init__.pyi @@ -41,9 +41,11 @@ if TYPE_CHECKING: JobType, ) from sift_client.sift_types.principal_attribute import ( + PrincipalAttributeAssignment, PrincipalAttributeEnumValue, PrincipalAttributeKey, - PrincipalAttributeValue, + PrincipalAttributeKeyUpdate, + PrincipalAttributeValueLike, PrincipalAttributeValueType, PrincipalType, ) @@ -54,11 +56,13 @@ if TYPE_CHECKING: ReportTemplateUpdate, ) from sift_client.sift_types.resource_attribute import ( - ResourceAttribute, + ResourceAttributeAssignment, ResourceAttributeEntity, ResourceAttributeEnumValue, ResourceAttributeKey, - ResourceAttributeKeyType, + ResourceAttributeKeyUpdate, + ResourceAttributeValueLike, + ResourceAttributeValueType, ) from sift_client.sift_types.rule import Rule, RuleCreate, RuleUpdate, RuleVersion from sift_client.sift_types.run import Run, RunCreate, RunUpdate @@ -77,6 +81,7 @@ if TYPE_CHECKING: TestStepType, TestStepUpdate, ) + from sift_client.sift_types.user import User class AssetsAPI: """Sync counterpart to `AssetsAPIAsync`. @@ -1204,7 +1209,7 @@ class PrincipalAttributesAPI: def _run(self, coro): ... def archive_assignments( self, - assignments: list[str | PrincipalAttributeValue], + assignments: list[str | PrincipalAttributeAssignment], *, principal_type: PrincipalType = PrincipalType.USER, ) -> None: @@ -1232,9 +1237,9 @@ class PrincipalAttributesAPI: key: str | PrincipalAttributeKey, principals: list[str], *, - value: Any, + value: PrincipalAttributeValueLike, principal_type: PrincipalType = PrincipalType.USER, - ) -> list[PrincipalAttributeValue]: + ) -> list[PrincipalAttributeAssignment]: """Assign a key's value to principals. Args: @@ -1277,7 +1282,7 @@ class PrincipalAttributesAPI: def get_assignment( self, *, assignment_id: str, principal_type: PrincipalType = PrincipalType.USER - ) -> PrincipalAttributeValue: + ) -> PrincipalAttributeAssignment: """Get a single assignment by ID.""" ... @@ -1316,7 +1321,7 @@ class PrincipalAttributesAPI: order_by: str | None = None, limit: int | None = None, page_size: int | None = None, - ) -> list[PrincipalAttributeValue]: + ) -> list[PrincipalAttributeAssignment]: """List principal attribute assignments. Args: @@ -1379,25 +1384,9 @@ class PrincipalAttributesAPI: """ ... - def resolve_user_id(self, email: str) -> str: - """Resolve a user's email (its user name) to a user ID. - - Raises: - ValueError: If no user with that email is found. - """ - ... - - def resolve_user_ids(self, emails: list[str]) -> dict[str, str]: - """Resolve user emails (their user names) to user IDs. - - Returns a mapping of email to user ID for the emails that were found. Emails with - no matching user are omitted. - """ - ... - def unarchive_assignments( self, - assignments: list[str | PrincipalAttributeValue], + assignments: list[str | PrincipalAttributeAssignment], *, principal_type: PrincipalType = PrincipalType.USER, ) -> None: @@ -1415,13 +1404,14 @@ class PrincipalAttributesAPI: ... def update_key( - self, - key: str | PrincipalAttributeKey, - *, - display_name: str | None = None, - description: str | None = None, + self, key: str | PrincipalAttributeKey, update: PrincipalAttributeKeyUpdate | dict ) -> PrincipalAttributeKey: - """Update a key's display name or description.""" + """Update a key. + + Args: + key: The key or key ID to update. + update: Updates to apply to the key. + """ ... class ReportTemplatesAPI: @@ -1836,7 +1826,7 @@ class ResourceAttributesAPI: ... def _run(self, coro): ... - def archive_assignments(self, assignments: list[str | ResourceAttribute]) -> None: + def archive_assignments(self, assignments: list[str | ResourceAttributeAssignment]) -> None: """Batch archive assignments.""" ... @@ -1861,12 +1851,12 @@ class ResourceAttributesAPI: key: str | ResourceAttributeKey, resources: list[ResourceAttributeEntity | Asset | Channel | Run | str], *, - value: Any, - ) -> list[ResourceAttribute]: + value: ResourceAttributeValueLike, + ) -> list[ResourceAttributeAssignment]: """Assign a key's value to resources. Args: - key: The key or key ID to assign. Its ``key_type`` determines how ``value`` is interpreted. + key: The key or key ID to assign. Its ``value_type`` determines how ``value`` is interpreted. resources: Resources to assign to. For currently supported resource types, pass ``Asset``, ``Channel``, or ``Run`` objects, their IDs, or ``ResourceAttributeEntity`` when you already know the resource type. @@ -1890,13 +1880,13 @@ class ResourceAttributesAPI: ... def create_key( - self, display_name: str, key_type: ResourceAttributeKeyType, *, description: str = "" + self, display_name: str, value_type: ResourceAttributeValueType, *, description: str = "" ) -> ResourceAttributeKey: """Create a resource attribute key. Args: display_name: The human-readable name of the key. - key_type: The value type of the key. + value_type: The value type of the key. description: Optional description. Returns: @@ -1908,7 +1898,7 @@ class ResourceAttributesAPI: """Find a single key matching the query. Raises if more than one matches.""" ... - def get_assignment(self, *, assignment_id: str) -> ResourceAttribute: + def get_assignment(self, *, assignment_id: str) -> ResourceAttributeAssignment: """Get a single assignment by ID.""" ... @@ -1926,7 +1916,7 @@ class ResourceAttributesAPI: ... def get_or_create_key( - self, display_name: str, key_type: ResourceAttributeKeyType, *, description: str = "" + self, display_name: str, value_type: ResourceAttributeValueType, *, description: str = "" ) -> ResourceAttributeKey: """Get a key by display name, creating it if it does not exist. @@ -1946,7 +1936,7 @@ class ResourceAttributesAPI: order_by: str | None = None, limit: int | None = None, page_size: int | None = None, - ) -> list[ResourceAttribute]: + ) -> list[ResourceAttributeAssignment]: """List resource attribute assignments. Args: @@ -1985,7 +1975,7 @@ class ResourceAttributesAPI: names: list[str] | None = None, name_contains: str | None = None, name_regex: str | re.Pattern | None = None, - key_type: ResourceAttributeKeyType | None = None, + value_type: ResourceAttributeValueType | None = None, include_archived: bool = False, filter_query: str | None = None, order_by: str | None = None, @@ -1999,7 +1989,7 @@ class ResourceAttributesAPI: names: Display names to filter by. name_contains: Substring match on the display name. name_regex: Regex match on the display name. - key_type: Filter to keys of this value type. + value_type: Filter to keys of this value type. include_archived: If True, include archived keys. filter_query: Explicit CEL query. order_by: Field and direction to order by. @@ -2011,7 +2001,7 @@ class ResourceAttributesAPI: """ ... - def unarchive_assignments(self, assignments: list[str | ResourceAttribute]) -> None: + def unarchive_assignments(self, assignments: list[str | ResourceAttributeAssignment]) -> None: """Batch unarchive assignments.""" ... @@ -2026,13 +2016,14 @@ class ResourceAttributesAPI: ... def update_key( - self, - key: str | ResourceAttributeKey, - *, - display_name: str | None = None, - description: str | None = None, + self, key: str | ResourceAttributeKey, update: ResourceAttributeKeyUpdate | dict ) -> ResourceAttributeKey: - """Update a key's display name or description.""" + """Update a key. + + Args: + key: The key or key ID to update. + update: Updates to apply to the key. + """ ... class RulesAPI: @@ -2916,3 +2907,83 @@ class TestResultsAPI: The updated TestStep. """ ... + +class UsersAPI: + """Sync counterpart to `UsersAPIAsync`. + + High-level API for users. + + A user's ``name`` is their login name, typically their email address. + """ + + def __init__(self, sift_client: SiftClient): + """Initialize the UsersAPI. + + Args: + sift_client: The Sift client to use. + """ + ... + + def _run(self, coro): ... + def find(self, **kwargs) -> User | None: + """Find a single user matching the query. Raises if more than one matches. + + Takes the same arguments as ``list_``. + """ + ... + + def get(self, *, user_id: str) -> User: + """Get a user by ID. + + Args: + user_id: The ID of the user to retrieve. + + Returns: + The User. + """ + ... + + def list_( + self, + *, + name: str | None = None, + names: list[str] | None = None, + name_contains: str | None = None, + name_regex: str | re.Pattern | None = None, + include_inactive: bool = False, + organization_id: str | None = None, + filter_query: str | None = None, + order_by: str | None = None, + limit: int | None = None, + page_size: int | None = None, + ) -> list[User]: + """List users with optional filtering. + + Args: + name: Exact login name (typically the email address). + names: Login names to filter by. + name_contains: Substring match on the login name. + name_regex: Regex match on the login name. + include_inactive: If True, include inactive users. + organization_id: Scope the search to this organization. Only supported when + listing active users. + filter_query: Explicit CEL query. + order_by: Field and direction to order by. + limit: Maximum number of users to return. + page_size: Results to fetch per request. + + Returns: + The matching users. + """ + ... + + def resolve_ids(self, emails: list[str]) -> dict[str, str]: + """Resolve user login emails (their user names) to user IDs. + + Returns a mapping of email to user ID for the emails that were found. Emails + with no matching user are omitted. + + Args: + emails: The login emails to resolve. + """ + ... diff --git a/python/lib/sift_client/resources/users.py b/python/lib/sift_client/resources/users.py new file mode 100644 index 000000000..d7a6ec131 --- /dev/null +++ b/python/lib/sift_client/resources/users.py @@ -0,0 +1,125 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +from sift_client._internal.low_level_wrappers.users import UsersLowLevelClient +from sift_client.resources._base import ResourceBase +from sift_client.util import cel_utils as cel + +if TYPE_CHECKING: + import re + + from sift_client.client import SiftClient + from sift_client.sift_types.user import User + + +class UsersAPIAsync(ResourceBase): + """High-level API for users. + + A user's ``name`` is their login name, typically their email address. + """ + + def __init__(self, sift_client: SiftClient): + """Initialize the UsersAPI. + + Args: + sift_client: The Sift client to use. + """ + super().__init__(sift_client) + self._low_level_client = UsersLowLevelClient(grpc_client=self.client.grpc_client) + + async def get(self, *, user_id: str) -> User: + """Get a user by ID. + + Args: + user_id: The ID of the user to retrieve. + + Returns: + The User. + """ + user = await self._low_level_client.get_user(user_id) + return self._apply_client_to_instance(user) + + async def list_( + self, + *, + name: str | None = None, + names: list[str] | None = None, + name_contains: str | None = None, + name_regex: str | re.Pattern | None = None, + include_inactive: bool = False, + organization_id: str | None = None, + filter_query: str | None = None, + order_by: str | None = None, + limit: int | None = None, + page_size: int | None = None, + ) -> list[User]: + """List users with optional filtering. + + Args: + name: Exact login name (typically the email address). + names: Login names to filter by. + name_contains: Substring match on the login name. + name_regex: Regex match on the login name. + include_inactive: If True, include inactive users. + organization_id: Scope the search to this organization. Only supported when + listing active users. + filter_query: Explicit CEL query. + order_by: Field and direction to order by. + limit: Maximum number of users to return. + page_size: Results to fetch per request. + + Returns: + The matching users. + """ + if include_inactive and organization_id is not None: + raise ValueError("organization_id is only supported when listing active users.") + + filter_parts = self._build_name_cel_filters( + name=name, names=names, name_contains=name_contains, name_regex=name_regex + ) + if filter_query: + filter_parts.append(filter_query) + query_filter = cel.and_(*filter_parts) or None + + if include_inactive: + users = await self._low_level_client.list_all_users( + query_filter=query_filter, + order_by=order_by, + max_results=limit, + **({"page_size": page_size} if page_size is not None else {}), + ) + else: + users = await self._low_level_client.list_all_active_users( + query_filter=query_filter, + order_by=order_by, + organization_id=organization_id, + max_results=limit, + **({"page_size": page_size} if page_size is not None else {}), + ) + return self._apply_client_to_instances(users) + + async def find(self, **kwargs) -> User | None: + """Find a single user matching the query. Raises if more than one matches. + + Takes the same arguments as ``list_``. + """ + users = await self.list_(**kwargs) + if len(users) > 1: + raise ValueError(f"Multiple ({len(users)}) users found for query") + return users[0] if users else None + + async def resolve_ids(self, emails: list[str]) -> dict[str, str]: + """Resolve user login emails (their user names) to user IDs. + + Returns a mapping of email to user ID for the emails that were found. Emails + with no matching user are omitted. + + Args: + emails: The login emails to resolve. + """ + wanted = list(dict.fromkeys(email for email in emails if email)) + if not wanted: + return {} + users = await self.list_(names=wanted) + return {user.name: user._id_or_error for user in users} diff --git a/python/lib/sift_client/sift_types/__init__.py b/python/lib/sift_client/sift_types/__init__.py index f8df88d61..edfaef3bc 100644 --- a/python/lib/sift_client/sift_types/__init__.py +++ b/python/lib/sift_client/sift_types/__init__.py @@ -165,9 +165,11 @@ RuleEvaluationStatusDetails, ) from sift_client.sift_types.principal_attribute import ( + PrincipalAttributeAssignment, PrincipalAttributeEnumValue, PrincipalAttributeKey, - PrincipalAttributeValue, + PrincipalAttributeKeyUpdate, + PrincipalAttributeValueLike, PrincipalAttributeValueType, PrincipalType, ) @@ -179,12 +181,14 @@ ReportTemplateUpdate, ) from sift_client.sift_types.resource_attribute import ( - ResourceAttribute, + ResourceAttributeAssignment, ResourceAttributeEntity, ResourceAttributeEntityType, ResourceAttributeEnumValue, ResourceAttributeKey, - ResourceAttributeKeyType, + ResourceAttributeKeyUpdate, + ResourceAttributeValueLike, + ResourceAttributeValueType, ) from sift_client.sift_types.rule import ( Rule, @@ -210,6 +214,7 @@ TestStepCreate, TestStepType, ) +from sift_client.sift_types.user import User, UserOrganization if "pytest" in sys.modules: # These are not test classes, so we need to set __test__ to False to avoid pytest warnings. @@ -251,9 +256,11 @@ "JobStatus", "JobStatusDetails", "JobType", + "PrincipalAttributeAssignment", "PrincipalAttributeEnumValue", "PrincipalAttributeKey", - "PrincipalAttributeValue", + "PrincipalAttributeKeyUpdate", + "PrincipalAttributeValueLike", "PrincipalAttributeValueType", "PrincipalType", "Report", @@ -264,12 +271,14 @@ "ReportTemplateRule", "ReportTemplateUpdate", "ReportUpdate", - "ResourceAttribute", + "ResourceAttributeAssignment", "ResourceAttributeEntity", "ResourceAttributeEntityType", "ResourceAttributeEnumValue", "ResourceAttributeKey", - "ResourceAttributeKeyType", + "ResourceAttributeKeyUpdate", + "ResourceAttributeValueLike", + "ResourceAttributeValueType", "Rule", "RuleAction", "RuleActionType", @@ -295,4 +304,6 @@ "TestStep", "TestStepCreate", "TestStepType", + "User", + "UserOrganization", ] diff --git a/python/lib/sift_client/sift_types/principal_attribute.py b/python/lib/sift_client/sift_types/principal_attribute.py index 78e755226..55e81408b 100644 --- a/python/lib/sift_client/sift_types/principal_attribute.py +++ b/python/lib/sift_client/sift_types/principal_attribute.py @@ -6,7 +6,7 @@ - ``PrincipalAttributeKey`` defines an attribute and its value type. - ``PrincipalAttributeEnumValue`` is an allowed value for an ``ENUM``/``SET_OF_ENUM`` key. -- ``PrincipalAttributeValue`` is a single assignment of a value to one principal. +- ``PrincipalAttributeAssignment`` is a single assignment of a value to one principal. The ``PrincipalAttributeKey`` acts as the entry point: enum values and assignments are managed through methods on a key instance. @@ -16,11 +16,11 @@ from datetime import datetime, timezone from enum import Enum -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING, Union from sift.principal_attributes.v1 import principal_attributes_pb2 as pa_pb -from sift_client.sift_types._base import BaseType +from sift_client.sift_types._base import BaseType, ModelUpdate if TYPE_CHECKING: from sift_client.client import SiftClient @@ -110,7 +110,17 @@ def __str__(self) -> str: return self.display_name -class PrincipalAttributeValue(BaseType[pa_pb.PrincipalAttributeValue, "PrincipalAttributeValue"]): +# Accepted value shapes for assign: a list of enum values (or their IDs) for +# SET_OF_ENUM keys, a single enum value (or its ID) for ENUM keys, a bool for +# BOOLEAN keys, or an int for NUMBER keys. +PrincipalAttributeValueLike = Union[ + bool, int, str, PrincipalAttributeEnumValue, "list[PrincipalAttributeEnumValue | str]" +] + + +class PrincipalAttributeAssignment( + BaseType[pa_pb.PrincipalAttributeValue, "PrincipalAttributeAssignment"] +): """A single assignment of a principal attribute value to a principal.""" organization_id: str @@ -130,7 +140,7 @@ class PrincipalAttributeValue(BaseType[pa_pb.PrincipalAttributeValue, "Principal @classmethod def _from_proto( cls, proto: pa_pb.PrincipalAttributeValue, sift_client: SiftClient | None = None - ) -> PrincipalAttributeValue: + ) -> PrincipalAttributeAssignment: which = proto.WhichOneof("value") return cls( proto=proto, @@ -179,7 +189,20 @@ def _apply_client_to_instance(self, client: SiftClient) -> None: if self.enum_value is not None: self.enum_value._apply_client_to_instance(client) - def archive(self) -> PrincipalAttributeValue: + @property + def value(self) -> PrincipalAttributeEnumValue | str | bool | int | None: + """The assigned value. + + The enum value for ``ENUM``/``SET_OF_ENUM`` keys (or its ID when details were + not returned), a bool for ``BOOLEAN`` keys, or an int for ``NUMBER`` keys. + """ + if self.enum_value_id is not None: + return self.enum_value if self.enum_value is not None else self.enum_value_id + if self.boolean_value is not None: + return self.boolean_value + return self.number_value + + def archive(self) -> PrincipalAttributeAssignment: """Archive this assignment.""" self.client.access_control.principal_attributes.archive_assignments( [self], principal_type=self.principal_type @@ -191,7 +214,7 @@ def archive(self) -> PrincipalAttributeValue: ) return self - def unarchive(self) -> PrincipalAttributeValue: + def unarchive(self) -> PrincipalAttributeAssignment: """Unarchive this assignment.""" self.client.access_control.principal_attributes.unarchive_assignments( [self], principal_type=self.principal_type @@ -268,9 +291,9 @@ def assign_to( self, principals: list[str], *, - value: Any, + value: PrincipalAttributeValueLike, principal_type: PrincipalType = PrincipalType.USER, - ) -> list[PrincipalAttributeValue]: + ) -> list[PrincipalAttributeAssignment]: """Assign a value to one or more principals for this key. Args: @@ -292,19 +315,19 @@ def assign_to( def list_assignments( self, *, principal_type: PrincipalType = PrincipalType.USER, include_archived: bool = False - ) -> list[PrincipalAttributeValue]: + ) -> list[PrincipalAttributeAssignment]: """List all assignments of this key for the given principal type.""" return self.client.access_control.principal_attributes.list_assignments( key=self, principal_type=principal_type, include_archived=include_archived ) - def update( - self, *, display_name: str | None = None, description: str | None = None - ) -> PrincipalAttributeKey: - """Update this key's display name or description.""" - updated = self.client.access_control.principal_attributes.update_key( - self, display_name=display_name, description=description - ) + def update(self, update: PrincipalAttributeKeyUpdate | dict) -> PrincipalAttributeKey: + """Update this key. + + Args: + update: Either a PrincipalAttributeKeyUpdate instance or a dict of fields to update. + """ + updated = self.client.access_control.principal_attributes.update_key(self, update=update) self._update(updated) return self @@ -326,3 +349,18 @@ def check_archive_impact(self) -> int: def __str__(self) -> str: return self.display_name + + +class PrincipalAttributeKeyUpdate(ModelUpdate[pa_pb.UpdatePrincipalAttributeKeyRequest]): + """Model of the PrincipalAttributeKey fields that can be updated.""" + + display_name: str | None = None + description: str | None = None + + def _get_proto_class(self) -> type[pa_pb.UpdatePrincipalAttributeKeyRequest]: + return pa_pb.UpdatePrincipalAttributeKeyRequest + + def _add_resource_id_to_proto(self, proto_msg: pa_pb.UpdatePrincipalAttributeKeyRequest): + if self._resource_id is None: + raise ValueError("Resource ID must be set before adding to proto") + proto_msg.principal_attribute_key_id = self._resource_id diff --git a/python/lib/sift_client/sift_types/resource_attribute.py b/python/lib/sift_client/sift_types/resource_attribute.py index a5e8cebb4..05a6e185f 100644 --- a/python/lib/sift_client/sift_types/resource_attribute.py +++ b/python/lib/sift_client/sift_types/resource_attribute.py @@ -5,7 +5,7 @@ - ``ResourceAttributeKey`` defines an attribute (e.g. ``licenses``) and its value type. - ``ResourceAttributeEnumValue`` is an allowed value for an ``ENUM``/``SET_OF_ENUM`` key. -- ``ResourceAttribute`` is a single assignment of a value to one resource. +- ``ResourceAttributeAssignment`` is a single assignment of a value to one resource. The ``ResourceAttributeKey`` acts as the entry point: enum values and assignments are managed through methods on a key instance. @@ -15,12 +15,12 @@ from datetime import datetime, timezone from enum import Enum -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING, Union from pydantic import BaseModel from sift.resource_attribute.v1 import resource_attribute_pb2 as ra_pb -from sift_client.sift_types._base import BaseType +from sift_client.sift_types._base import BaseType, ModelUpdate if TYPE_CHECKING: from sift_client.client import SiftClient @@ -29,7 +29,7 @@ from sift_client.sift_types.run import Run -class ResourceAttributeKeyType(Enum): +class ResourceAttributeValueType(Enum): """Value type of a resource attribute key.""" UNSPECIFIED = ra_pb.RESOURCE_ATTRIBUTE_KEY_TYPE_UNSPECIFIED @@ -149,7 +149,15 @@ def __str__(self) -> str: return self.display_name -class ResourceAttribute(BaseType[ra_pb.ResourceAttribute, "ResourceAttribute"]): +# Accepted value shapes for assign: a list of enum values (or their IDs) for +# SET_OF_ENUM keys, a single enum value (or its ID) for ENUM keys, a bool for +# BOOLEAN keys, or an int for NUMBER keys. +ResourceAttributeValueLike = Union[ + bool, int, str, ResourceAttributeEnumValue, "list[ResourceAttributeEnumValue | str]" +] + + +class ResourceAttributeAssignment(BaseType[ra_pb.ResourceAttribute, "ResourceAttributeAssignment"]): """A single assignment of a resource attribute value to a supported resource.""" organization_id: str @@ -168,7 +176,7 @@ class ResourceAttribute(BaseType[ra_pb.ResourceAttribute, "ResourceAttribute"]): @classmethod def _from_proto( cls, proto: ra_pb.ResourceAttribute, sift_client: SiftClient | None = None - ) -> ResourceAttribute: + ) -> ResourceAttributeAssignment: which = proto.WhichOneof("value") return cls( proto=proto, @@ -220,7 +228,20 @@ def _apply_client_to_instance(self, client: SiftClient) -> None: if self.enum_value is not None: self.enum_value._apply_client_to_instance(client) - def archive(self) -> ResourceAttribute: + @property + def value(self) -> ResourceAttributeEnumValue | str | bool | int | None: + """The assigned value. + + The enum value for ``ENUM``/``SET_OF_ENUM`` keys (or its ID when details were + not returned), a bool for ``BOOLEAN`` keys, or an int for ``NUMBER`` keys. + """ + if self.enum_value_id is not None: + return self.enum_value if self.enum_value is not None else self.enum_value_id + if self.boolean_value is not None: + return self.boolean_value + return self.number_value + + def archive(self) -> ResourceAttributeAssignment: """Archive this assignment.""" self.client.access_control.resource_attributes.archive_assignments([self]) self._update( @@ -230,7 +251,7 @@ def archive(self) -> ResourceAttribute: ) return self - def unarchive(self) -> ResourceAttribute: + def unarchive(self) -> ResourceAttributeAssignment: """Unarchive this assignment.""" self.client.access_control.resource_attributes.unarchive_assignments([self]) self._update( @@ -247,7 +268,7 @@ class ResourceAttributeKey(BaseType[ra_pb.ResourceAttributeKey, "ResourceAttribu organization_id: str display_name: str description: str - key_type: ResourceAttributeKeyType + value_type: ResourceAttributeValueType created_date: datetime created_by_user_id: str modified_date: datetime @@ -265,7 +286,7 @@ def _from_proto( organization_id=proto.organization_id, display_name=proto.display_name, description=proto.description, - key_type=ResourceAttributeKeyType(proto.type), + value_type=ResourceAttributeValueType(proto.type), created_date=proto.created_date.ToDatetime(tzinfo=timezone.utc), created_by_user_id=proto.created_by_user_id, modified_date=proto.modified_date.ToDatetime(tzinfo=timezone.utc), @@ -303,8 +324,8 @@ def assign_to( self, resources: list[ResourceAttributeEntity | Asset | Channel | Run | str], *, - value: Any, - ) -> list[ResourceAttribute]: + value: ResourceAttributeValueLike, + ) -> list[ResourceAttributeAssignment]: """Assign a value to one or more resources for this key. Args: @@ -321,19 +342,21 @@ def assign_to( """ return self.client.access_control.resource_attributes.assign(self, resources, value=value) - def list_assignments(self, *, include_archived: bool = False) -> list[ResourceAttribute]: + def list_assignments( + self, *, include_archived: bool = False + ) -> list[ResourceAttributeAssignment]: """List all assignments of this key.""" return self.client.access_control.resource_attributes.list_assignments( key=self, include_archived=include_archived ) - def update( - self, *, display_name: str | None = None, description: str | None = None - ) -> ResourceAttributeKey: - """Update this key's display name or description.""" - updated = self.client.access_control.resource_attributes.update_key( - self, display_name=display_name, description=description - ) + def update(self, update: ResourceAttributeKeyUpdate | dict) -> ResourceAttributeKey: + """Update this key. + + Args: + update: Either a ResourceAttributeKeyUpdate instance or a dict of fields to update. + """ + updated = self.client.access_control.resource_attributes.update_key(self, update=update) self._update(updated) return self @@ -355,3 +378,18 @@ def check_archive_impact(self) -> int: def __str__(self) -> str: return self.display_name + + +class ResourceAttributeKeyUpdate(ModelUpdate[ra_pb.UpdateResourceAttributeKeyRequest]): + """Model of the ResourceAttributeKey fields that can be updated.""" + + display_name: str | None = None + description: str | None = None + + def _get_proto_class(self) -> type[ra_pb.UpdateResourceAttributeKeyRequest]: + return ra_pb.UpdateResourceAttributeKeyRequest + + def _add_resource_id_to_proto(self, proto_msg: ra_pb.UpdateResourceAttributeKeyRequest): + if self._resource_id is None: + raise ValueError("Resource ID must be set before adding to proto") + proto_msg.resource_attribute_key_id = self._resource_id diff --git a/python/lib/sift_client/sift_types/user.py b/python/lib/sift_client/sift_types/user.py new file mode 100644 index 000000000..3dfe2ddb0 --- /dev/null +++ b/python/lib/sift_client/sift_types/user.py @@ -0,0 +1,57 @@ +"""Domain types for users. + +A ``User`` identifies a person in a Sift organization. The user's ``name`` is their +login name, typically their email address. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from pydantic import BaseModel +from sift.common.type.v1.user_pb2 import User as UserProto + +from sift_client.sift_types._base import BaseType + +if TYPE_CHECKING: + from sift_client.client import SiftClient + + +class UserOrganization(BaseModel): + """An organization a user belongs to.""" + + organization_id: str + organization_name: str + is_abac_enabled: bool | None + + +class User(BaseType[UserProto, "User"]): + """A Sift user. + + ``name`` is the user's login name, typically their email address. + """ + + name: str + organizations: list[UserOrganization] + + @classmethod + def _from_proto(cls, proto: UserProto, sift_client: SiftClient | None = None) -> User: + return cls( + proto=proto, + id_=proto.user_id, + name=proto.user_name, + organizations=[ + UserOrganization( + organization_id=org.organization_id, + organization_name=org.organization_name, + is_abac_enabled=( + org.is_abac_enabled if org.HasField("is_abac_enabled") else None + ), + ) + for org in proto.organizations + ], + _client=sift_client, + ) + + def __str__(self) -> str: + return self.name diff --git a/python/lib/sift_client/util/cel_utils.py b/python/lib/sift_client/util/cel_utils.py index ea74f2171..bae5e14ad 100644 --- a/python/lib/sift_client/util/cel_utils.py +++ b/python/lib/sift_client/util/cel_utils.py @@ -10,6 +10,12 @@ from typing import Any +def _quote(value: str) -> str: + """Quote a string as a CEL single-quoted literal, escaping backslashes and quotes.""" + escaped = value.replace("\\", "\\\\").replace("'", "\\'") + return f"'{escaped}'" + + def in_(field: str, vals: list[str]) -> str: """Generates a CEL expression that checks for `field` membership in `vals`. @@ -23,7 +29,7 @@ def in_(field: str, vals: list[str]) -> str: if not vals: return "" - quoted_vals = [f"'{val}'" for val in vals] + quoted_vals = [_quote(val) for val in vals] return f"{field} in [{','.join(quoted_vals)}]" @@ -52,7 +58,7 @@ def equals(key: str, value: Any) -> str: if value is None: return equals_null(key) elif isinstance(value, str): - return f"{key} == '{value}'" + return f"{key} == {_quote(value)}" elif isinstance(value, bool): return f"{key} == {str(value).lower()}" else: @@ -166,7 +172,7 @@ def contains(field: str, value: str) -> str: Returns: A CEL expression string """ - return f"{field}.contains('{value}')" + return f"{field}.contains({_quote(value)})" def match(field: str, query: str | re.Pattern) -> str: @@ -181,9 +187,9 @@ def match(field: str, query: str | re.Pattern) -> str: """ if isinstance(query, re.Pattern): query = str(query.pattern) - # Double-escape any backslashes that already exist in the regex - escaped_regex = query.replace("\\", "\\\\") - return f"{field}.matches('{escaped_regex}')" + # _quote double-escapes backslashes so the CEL string parser hands the regex + # engine the original pattern, and escapes quotes so the literal stays intact. + return f"{field}.matches({_quote(query)})" def greater_than(field: str, value: int | float | datetime | timedelta) -> str: diff --git a/python/lib/sift_client/util/util.py b/python/lib/sift_client/util/util.py index 5474c462c..80da28352 100644 --- a/python/lib/sift_client/util/util.py +++ b/python/lib/sift_client/util/util.py @@ -18,6 +18,7 @@ RunsAPIAsync, TagsAPIAsync, TestResultsAPIAsync, + UsersAPIAsync, ) from sift_client.resources.access_control import AccessControlAPIAsync @@ -64,6 +65,9 @@ class AsyncAPIs(NamedTuple): test_results: TestResultsAPIAsync """Instance of the Test Results API for making asynchronous requests.""" + users: UsersAPIAsync + """Instance of the Users API for making asynchronous requests.""" + data_export: DataExportAPIAsync """Instance of the Data Export API for making asynchronous requests.""" From d007cf45712f4d561694283dd762eb9ef76b2b0a Mon Sep 17 00:00:00 2001 From: Wei Qi Lu Date: Wed, 8 Jul 2026 10:30:15 -0700 Subject: [PATCH 09/21] restructure attributes into nested keys/enum_values/assignments sub-resources --- python/CHANGELOG.md | 6 +- .../resources/test_principal_attributes.py | 61 ++- .../resources/test_resource_attributes.py | 131 +++-- .../sift_types/test_principal_attribute.py | 8 +- .../sift_types/test_resource_attribute.py | 20 +- python/lib/sift_client/resources/__init__.py | 32 +- .../access_control/principal_attributes.py | 128 ++++- .../access_control/resource_attributes.py | 298 ++++++---- .../resources/sync_stubs/__init__.py | 52 +- .../resources/sync_stubs/__init__.pyi | 518 +++++++++++------- .../sift_types/principal_attribute.py | 30 +- .../sift_types/resource_attribute.py | 32 +- 12 files changed, 852 insertions(+), 464 deletions(-) diff --git a/python/CHANGELOG.md b/python/CHANGELOG.md index bd8a54afb..3095793d9 100644 --- a/python/CHANGELOG.md +++ b/python/CHANGELOG.md @@ -70,7 +70,7 @@ An attribute key is the entry point. Create or fetch a key, define its enum valu ```python from sift_client.sift_types import ResourceAttributeValueType -key = client.access_control.resource_attributes.get_or_create_key( +key = client.access_control.resource_attributes.keys.get_or_create( "licenses", ResourceAttributeValueType.SET_OF_ENUM, ) @@ -83,7 +83,7 @@ Principal attributes accept user IDs or email addresses, resolving emails to use ```python from sift_client.sift_types import PrincipalAttributeValueType -key = client.access_control.principal_attributes.get_or_create_key( +key = client.access_control.principal_attributes.keys.get_or_create( "licenses", PrincipalAttributeValueType.SET_OF_ENUM, ) @@ -91,7 +91,7 @@ licenses = key.get_or_create_enum_values(["LICENSE_A"]) key.assign_to(["user@example.com"], value=licenses) ``` -Keys, enum values, and assignments each support create, get, list, update, and archive operations. For `SET_OF_ENUM` keys, an assignment replaces the full value set on each target. +Keys, enum values, and assignments are managed through the nested `keys`, `enum_values`, and `assignments` APIs on each side (for example `client.access_control.resource_attributes.assignments.list_()`), each supporting the standard create, get, list, update, and archive operations. For `SET_OF_ENUM` keys, an assignment replaces the full value set on each target. #### Users diff --git a/python/lib/sift_client/_tests/resources/test_principal_attributes.py b/python/lib/sift_client/_tests/resources/test_principal_attributes.py index 1671cbba3..0dbbfc52b 100644 --- a/python/lib/sift_client/_tests/resources/test_principal_attributes.py +++ b/python/lib/sift_client/_tests/resources/test_principal_attributes.py @@ -1,6 +1,7 @@ """Unit tests for the principal attributes high-level API. -These mock the low-level client and the users API; they are not integration tests. +These mock the low-level client and the users API, and exercise the nested +keys/enum_values/assignments sub-resources. They are not integration tests. """ from unittest.mock import AsyncMock, MagicMock @@ -19,7 +20,9 @@ def _api() -> PrincipalAttributesAPIAsync: client = MagicMock() api = PrincipalAttributesAPIAsync(client) - api._low_level_client = MagicMock() + api.keys._low_level_client = MagicMock() + api.enum_values._low_level_client = MagicMock() + api.assignments._low_level_client = MagicMock() return api @@ -33,17 +36,17 @@ def _key() -> PrincipalAttributeKey: ) -class TestAssign: +class TestAssignmentsCreate: @pytest.mark.asyncio async def test_fetches_key_when_assigning_by_key_id(self): api = _api() - api._low_level_client.get_key = AsyncMock(return_value=_key()) - api._low_level_client.batch_create_values = AsyncMock(return_value=[]) + api.assignments._low_level_client.get_key = AsyncMock(return_value=_key()) + api.assignments._low_level_client.batch_create_values = AsyncMock(return_value=[]) - await api.assign("pk1", ["u1"], value=["e_a"]) + await api.assignments.create("pk1", ["u1"], value=["e_a"]) - api._low_level_client.get_key.assert_awaited_once_with("pk1") - kwargs = api._low_level_client.batch_create_values.call_args.kwargs + api.assignments._low_level_client.get_key.assert_awaited_once_with("pk1") + kwargs = api.assignments._low_level_client.batch_create_values.call_args.kwargs assert kwargs["key_id"] == "pk1" assert kwargs["principal_ids"] == ["u1"] assert kwargs["enum_value_ids"] == ["e_a"] @@ -52,12 +55,12 @@ async def test_fetches_key_when_assigning_by_key_id(self): async def test_resolves_emails_via_users_api_and_keeps_raw_ids(self): api = _api() api.client.async_.users.resolve_ids = AsyncMock(return_value={"alice@x.com": "u1"}) - api._low_level_client.batch_create_values = AsyncMock(return_value=[]) + api.assignments._low_level_client.batch_create_values = AsyncMock(return_value=[]) - await api.assign(_key(), ["alice@x.com", "raw_id"], value=["e_a"]) + await api.assignments.create(_key(), ["alice@x.com", "raw_id"], value=["e_a"]) api.client.async_.users.resolve_ids.assert_awaited_once_with(["alice@x.com"]) - kwargs = api._low_level_client.batch_create_values.call_args.kwargs + kwargs = api.assignments._low_level_client.batch_create_values.call_args.kwargs assert kwargs["principal_ids"] == ["u1", "raw_id"] assert kwargs["principal_type"] == PrincipalType.USER.value assert kwargs["enum_value_ids"] == ["e_a"] @@ -68,49 +71,57 @@ async def test_unresolvable_email_raises(self): api.client.async_.users.resolve_ids = AsyncMock(return_value={}) with pytest.raises(ValueError, match="No user found"): - await api.assign(_key(), ["ghost@x.com"], value=["e_a"]) + await api.assignments.create(_key(), ["ghost@x.com"], value=["e_a"]) @pytest.mark.asyncio async def test_email_with_non_user_principal_type_raises(self): api = _api() with pytest.raises(ValueError, match="only supported for USER"): - await api.assign( + await api.assignments.create( _key(), ["group@x.com"], value=["e_a"], principal_type=PrincipalType.USER_GROUP ) -class TestListAssignmentsRouting: +class TestAssignmentsListRouting: @pytest.mark.asyncio async def test_uses_key_values_rpc_when_key_given(self): api = _api() - api._low_level_client.list_all_key_values = AsyncMock(return_value=[]) - api._low_level_client.list_all_values = AsyncMock( + api.assignments._low_level_client.list_all_key_values = AsyncMock(return_value=[]) + api.assignments._low_level_client.list_all_values = AsyncMock( side_effect=AssertionError("should use key values") ) - await api.list_assignments(key=_key()) + await api.assignments.list_(key=_key()) - api._low_level_client.list_all_key_values.assert_awaited_once() + api.assignments._low_level_client.list_all_key_values.assert_awaited_once() @pytest.mark.asyncio async def test_uses_all_values_rpc_without_key(self): api = _api() - api._low_level_client.list_all_values = AsyncMock(return_value=[]) + api.assignments._low_level_client.list_all_values = AsyncMock(return_value=[]) - await api.list_assignments() + await api.assignments.list_() - api._low_level_client.list_all_values.assert_awaited_once() + api.assignments._low_level_client.list_all_values.assert_awaited_once() -class TestUpdateKey: +class TestKeysUpdate: @pytest.mark.asyncio async def test_dict_update_is_validated_and_carries_key_id(self): api = _api() - api._low_level_client.update_key = AsyncMock(return_value=_key()) + api.keys._low_level_client.update_key = AsyncMock(return_value=_key()) - await api.update_key("pk1", {"display_name": "new name"}) + await api.keys.update("pk1", {"display_name": "new name"}) - update = api._low_level_client.update_key.call_args.kwargs["update"] + update = api.keys._low_level_client.update_key.call_args.kwargs["update"] assert isinstance(update, PrincipalAttributeKeyUpdate) assert update.display_name == "new name" assert update.resource_id == "pk1" + + +class TestNestedWiring: + def test_sub_resources_share_the_parent_client(self): + api = _api() + assert api.keys.client is api.client + assert api.enum_values.client is api.client + assert api.assignments.client is api.client diff --git a/python/lib/sift_client/_tests/resources/test_resource_attributes.py b/python/lib/sift_client/_tests/resources/test_resource_attributes.py index 2c83d102d..b18d1f2d2 100644 --- a/python/lib/sift_client/_tests/resources/test_resource_attributes.py +++ b/python/lib/sift_client/_tests/resources/test_resource_attributes.py @@ -1,8 +1,9 @@ """Unit tests for the resource attributes high-level API. These mock the low-level client and exercise the resource-layer orchestration -(get-or-create de-duplication, value/entity resolution, batching). They are not -integration tests and run without a backend. +(get-or-create de-duplication, value/entity resolution, batching) across the +nested keys/enum_values/assignments sub-resources. They are not integration +tests and run without a backend. """ from types import SimpleNamespace @@ -25,7 +26,9 @@ def _api() -> ResourceAttributesAPIAsync: client = MagicMock() api = ResourceAttributesAPIAsync(client) - api._low_level_client = MagicMock() + api.keys._low_level_client = MagicMock() + api.enum_values._low_level_client = MagicMock() + api.assignments._low_level_client = MagicMock() return api @@ -49,62 +52,70 @@ def _matched_resource(resource_id: str): return SimpleNamespace(_id_or_error=resource_id) -class TestGetOrCreateKey: +class TestKeysGetOrCreate: @pytest.mark.asyncio async def test_returns_existing_without_creating(self): api = _api() - api._low_level_client.list_all_keys = AsyncMock(return_value=[_key()]) - api._low_level_client.create_key = AsyncMock(side_effect=AssertionError("must not create")) + api.keys._low_level_client.list_all_keys = AsyncMock(return_value=[_key()]) + api.keys._low_level_client.create_key = AsyncMock( + side_effect=AssertionError("must not create") + ) - key = await api.get_or_create_key("licenses", ResourceAttributeValueType.SET_OF_ENUM) + key = await api.keys.get_or_create("licenses", ResourceAttributeValueType.SET_OF_ENUM) assert key.id_ == "k1" @pytest.mark.asyncio async def test_creates_when_missing(self): api = _api() - api._low_level_client.list_all_keys = AsyncMock(return_value=[]) - api._low_level_client.create_key = AsyncMock(return_value=_key()) + api.keys._low_level_client.list_all_keys = AsyncMock(return_value=[]) + api.keys._low_level_client.create_key = AsyncMock(return_value=_key()) - key = await api.get_or_create_key("licenses", ResourceAttributeValueType.SET_OF_ENUM) + key = await api.keys.get_or_create("licenses", ResourceAttributeValueType.SET_OF_ENUM) - api._low_level_client.create_key.assert_awaited_once() + api.keys._low_level_client.create_key.assert_awaited_once() assert key.id_ == "k1" -class TestGetOrCreateEnumValues: +class TestEnumValuesGetOrCreate: @pytest.mark.asyncio async def test_only_creates_missing_and_preserves_order(self): api = _api() - api._low_level_client.list_all_enum_values = AsyncMock(return_value=[_enum("e_a", "LIC_A")]) - api._low_level_client.create_enum_value = AsyncMock(return_value=_enum("e_b", "LIC_B")) + api.enum_values._low_level_client.list_all_enum_values = AsyncMock( + return_value=[_enum("e_a", "LIC_A")] + ) + api.enum_values._low_level_client.create_enum_value = AsyncMock( + return_value=_enum("e_b", "LIC_B") + ) - values = await api.get_or_create_enum_values(_key(), ["LIC_A", "LIC_B"]) + values = await api.enum_values.get_or_create(_key(), ["LIC_A", "LIC_B"]) assert [v.display_name for v in values] == ["LIC_A", "LIC_B"] - assert api._low_level_client.create_enum_value.await_count == 1 + assert api.enum_values._low_level_client.create_enum_value.await_count == 1 -class TestAssignValueResolution: +class TestAssignmentsCreateValueResolution: @pytest.mark.asyncio async def test_set_of_enum_uses_id_list(self): api = _api() - api._low_level_client.batch_create_resource_attributes = AsyncMock(return_value=[]) + api.assignments._low_level_client.batch_create_resource_attributes = AsyncMock( + return_value=[] + ) - await api.assign( + await api.assignments.create( _key(ra.RESOURCE_ATTRIBUTE_KEY_TYPE_SET_OF_ENUM), [ResourceAttributeEntity.for_channel("ch1")], value=[_enum("e_a", "LIC_A"), "e_b"], ) - kwargs = api._low_level_client.batch_create_resource_attributes.call_args.kwargs + kwargs = api.assignments._low_level_client.batch_create_resource_attributes.call_args.kwargs assert kwargs["enum_value_ids"] == ["e_a", "e_b"] @pytest.mark.asyncio async def test_boolean_key_requires_bool(self): api = _api() with pytest.raises(TypeError, match="BOOLEAN keys require a bool"): - await api.assign( + await api.assignments.create( _key(ra.RESOURCE_ATTRIBUTE_KEY_TYPE_BOOLEAN), [ResourceAttributeEntity.for_channel("ch1")], value="not-a-bool", @@ -114,7 +125,7 @@ async def test_boolean_key_requires_bool(self): async def test_enum_key_rejects_multiple_values(self): api = _api() with pytest.raises(ValueError, match="exactly one enum value"): - await api.assign( + await api.assignments.create( _key(ra.RESOURCE_ATTRIBUTE_KEY_TYPE_ENUM), [ResourceAttributeEntity.for_channel("ch1")], value=["e_a", "e_b"], @@ -125,12 +136,14 @@ async def test_resolves_domain_objects_to_entities(self): from sift_client.sift_types.asset import Asset api = _api() - api._low_level_client.batch_create_resource_attributes = AsyncMock(return_value=[]) + api.assignments._low_level_client.batch_create_resource_attributes = AsyncMock( + return_value=[] + ) asset = Asset._from_proto(_asset_proto()) - await api.assign(_key(), [asset], value=["e_a"]) + await api.assignments.create(_key(), [asset], value=["e_a"]) - kwargs = api._low_level_client.batch_create_resource_attributes.call_args.kwargs + kwargs = api.assignments._low_level_client.batch_create_resource_attributes.call_args.kwargs entities = kwargs["entities"] assert entities[0].entity_type == ResourceAttributeEntity.for_asset("a1").entity_type assert entities[0].entity_id == "a1" @@ -138,25 +151,31 @@ async def test_resolves_domain_objects_to_entities(self): @pytest.mark.asyncio async def test_fetches_key_when_assigning_by_key_id(self): api = _api() - api._low_level_client.get_key = AsyncMock(return_value=_key()) - api._low_level_client.batch_create_resource_attributes = AsyncMock(return_value=[]) + api.assignments._low_level_client.get_key = AsyncMock(return_value=_key()) + api.assignments._low_level_client.batch_create_resource_attributes = AsyncMock( + return_value=[] + ) - await api.assign("k1", [ResourceAttributeEntity.for_channel("ch1")], value=["e_a"]) + await api.assignments.create( + "k1", [ResourceAttributeEntity.for_channel("ch1")], value=["e_a"] + ) - api._low_level_client.get_key.assert_awaited_once_with("k1") - kwargs = api._low_level_client.batch_create_resource_attributes.call_args.kwargs + api.assignments._low_level_client.get_key.assert_awaited_once_with("k1") + kwargs = api.assignments._low_level_client.batch_create_resource_attributes.call_args.kwargs assert kwargs["key_id"] == "k1" assert kwargs["enum_value_ids"] == ["e_a"] @pytest.mark.asyncio async def test_resolves_resource_ids_to_supported_entity_type(self): api = _api() - api._low_level_client.batch_create_resource_attributes = AsyncMock(return_value=[]) + api.assignments._low_level_client.batch_create_resource_attributes = AsyncMock( + return_value=[] + ) api.client.async_.assets.list_ = AsyncMock(return_value=[]) api.client.async_.channels.list_ = AsyncMock(return_value=[_matched_resource("ch1")]) api.client.async_.runs.list_ = AsyncMock(return_value=[]) - await api.assign(_key(), ["ch1", "ch1"], value=["e_a"]) + await api.assignments.create(_key(), ["ch1", "ch1"], value=["e_a"]) api.client.async_.assets.list_.assert_awaited_once_with( asset_ids=["ch1"], include_archived=True @@ -165,7 +184,7 @@ async def test_resolves_resource_ids_to_supported_entity_type(self): api.client.async_.runs.list_.assert_awaited_once_with( run_ids=["ch1"], include_archived=True ) - kwargs = api._low_level_client.batch_create_resource_attributes.call_args.kwargs + kwargs = api.assignments._low_level_client.batch_create_resource_attributes.call_args.kwargs entities = kwargs["entities"] assert entities[0].entity_type == ResourceAttributeEntityType.CHANNEL assert entities[0].entity_id == "ch1" @@ -175,43 +194,49 @@ async def test_resolves_resource_ids_to_supported_entity_type(self): @pytest.mark.asyncio async def test_rejects_unknown_resource_ids(self): api = _api() - api._low_level_client.batch_create_resource_attributes = AsyncMock(return_value=[]) + api.assignments._low_level_client.batch_create_resource_attributes = AsyncMock( + return_value=[] + ) api.client.async_.assets.list_ = AsyncMock(return_value=[]) api.client.async_.channels.list_ = AsyncMock(return_value=[]) api.client.async_.runs.list_ = AsyncMock(return_value=[]) with pytest.raises(ValueError, match="Could not resolve resource ID"): - await api.assign(_key(), ["missing"], value=["e_a"]) + await api.assignments.create(_key(), ["missing"], value=["e_a"]) - api._low_level_client.batch_create_resource_attributes.assert_not_called() + api.assignments._low_level_client.batch_create_resource_attributes.assert_not_called() @pytest.mark.asyncio async def test_rejects_resource_entity_types_outside_current_supported_targets(self): api = _api() - api._low_level_client.batch_create_resource_attributes = AsyncMock(return_value=[]) + api.assignments._low_level_client.batch_create_resource_attributes = AsyncMock( + return_value=[] + ) unsupported = ResourceAttributeEntity( entity_id="unknown", entity_type=ResourceAttributeEntityType.UNSPECIFIED, ) with pytest.raises(ValueError, match="currently support assets, channels, and runs"): - await api.assign(_key(), [unsupported], value=["e_a"]) + await api.assignments.create(_key(), [unsupported], value=["e_a"]) - api._low_level_client.batch_create_resource_attributes.assert_not_called() + api.assignments._low_level_client.batch_create_resource_attributes.assert_not_called() @pytest.mark.asyncio async def test_rejects_resource_assignment_filters_outside_current_supported_targets(self): api = _api() - api._low_level_client.list_all_resource_attributes_by_entity = AsyncMock(return_value=[]) + api.assignments._low_level_client.list_all_resource_attributes_by_entity = AsyncMock( + return_value=[] + ) unsupported = ResourceAttributeEntity( entity_id="unknown", entity_type=ResourceAttributeEntityType.UNSPECIFIED, ) with pytest.raises(ValueError, match="currently support assets, channels, and runs"): - await api.list_assignments(resource=unsupported) + await api.assignments.list_(resource=unsupported) - api._low_level_client.list_all_resource_attributes_by_entity.assert_not_called() + api.assignments._low_level_client.list_all_resource_attributes_by_entity.assert_not_called() def _asset_proto(): @@ -221,15 +246,15 @@ def _asset_proto(): return proto -class TestUpdateKey: +class TestKeysUpdate: @pytest.mark.asyncio async def test_dict_update_is_validated_and_carries_key_id(self): api = _api() - api._low_level_client.update_key = AsyncMock(return_value=_key()) + api.keys._low_level_client.update_key = AsyncMock(return_value=_key()) - await api.update_key("k1", {"display_name": "new name"}) + await api.keys.update("k1", {"display_name": "new name"}) - update = api._low_level_client.update_key.call_args.kwargs["update"] + update = api.keys._low_level_client.update_key.call_args.kwargs["update"] assert isinstance(update, ResourceAttributeKeyUpdate) assert update.display_name == "new name" assert update.resource_id == "k1" @@ -237,10 +262,18 @@ async def test_dict_update_is_validated_and_carries_key_id(self): @pytest.mark.asyncio async def test_model_update_takes_id_from_key_object(self): api = _api() - api._low_level_client.update_key = AsyncMock(return_value=_key()) + api.keys._low_level_client.update_key = AsyncMock(return_value=_key()) - await api.update_key(_key(), ResourceAttributeKeyUpdate(description="d")) + await api.keys.update(_key(), ResourceAttributeKeyUpdate(description="d")) - update = api._low_level_client.update_key.call_args.kwargs["update"] + update = api.keys._low_level_client.update_key.call_args.kwargs["update"] assert update.resource_id == "k1" assert update.description == "d" + + +class TestNestedWiring: + def test_sub_resources_share_the_parent_client(self): + api = _api() + assert api.keys.client is api.client + assert api.enum_values.client is api.client + assert api.assignments.client is api.client diff --git a/python/lib/sift_client/_tests/sift_types/test_principal_attribute.py b/python/lib/sift_client/_tests/sift_types/test_principal_attribute.py index 0db74b384..56aaa8090 100644 --- a/python/lib/sift_client/_tests/sift_types/test_principal_attribute.py +++ b/python/lib/sift_client/_tests/sift_types/test_principal_attribute.py @@ -68,13 +68,13 @@ def test_archive_passes_principal_type_and_refreshes(self, mock_client): boolean_value=True, is_archived=True, ) - mock_client.access_control.principal_attributes.get_assignment.return_value = ( + mock_client.access_control.principal_attributes.assignments.get.return_value = ( PrincipalAttributeAssignment._from_proto(archived_proto) ) result = value.archive() - mock_client.access_control.principal_attributes.archive_assignments.assert_called_once_with( + mock_client.access_control.principal_attributes.assignments.archive.assert_called_once_with( [value], principal_type=PrincipalType.USER ) assert result is value @@ -106,11 +106,11 @@ class TestPrincipalAttributeKeyConvenience: def test_assign_to_defaults_to_user(self, mock_client): key = PrincipalAttributeKey._from_proto(_key_proto()) key._apply_client_to_instance(mock_client) - mock_client.access_control.principal_attributes.assign.return_value = [] + mock_client.access_control.principal_attributes.assignments.create.return_value = [] key.assign_to(["u1"], value=["LIC_A"]) - mock_client.access_control.principal_attributes.assign.assert_called_once_with( + mock_client.access_control.principal_attributes.assignments.create.assert_called_once_with( key, ["u1"], value=["LIC_A"], principal_type=PrincipalType.USER ) diff --git a/python/lib/sift_client/_tests/sift_types/test_resource_attribute.py b/python/lib/sift_client/_tests/sift_types/test_resource_attribute.py index ef9bb2f65..83cf61815 100644 --- a/python/lib/sift_client/_tests/sift_types/test_resource_attribute.py +++ b/python/lib/sift_client/_tests/sift_types/test_resource_attribute.py @@ -119,13 +119,13 @@ def test_archive_refreshes_and_returns_self(self, mock_client): archived = ra.ResourceAttribute( resource_attribute_id="a1", resource_attribute_key_id="k1", is_archived=True ) - mock_client.access_control.resource_attributes.get_assignment.return_value = ( + mock_client.access_control.resource_attributes.assignments.get.return_value = ( ResourceAttributeAssignment._from_proto(archived) ) result = attr.archive() - mock_client.access_control.resource_attributes.archive_assignments.assert_called_once_with( + mock_client.access_control.resource_attributes.assignments.archive.assert_called_once_with( [attr] ) assert result is attr @@ -137,22 +137,24 @@ def test_archive_delegates_and_updates_in_place(self, mock_client): key = ResourceAttributeKey._from_proto(_key_proto()) key._apply_client_to_instance(mock_client) archived = ResourceAttributeKey._from_proto(_key_proto(is_archived=True)) - mock_client.access_control.resource_attributes.archive_key.return_value = archived + mock_client.access_control.resource_attributes.keys.archive.return_value = archived result = key.archive() - mock_client.access_control.resource_attributes.archive_key.assert_called_once_with(key) + mock_client.access_control.resource_attributes.keys.archive.assert_called_once_with(key) assert result is key assert key.is_archived is True def test_assign_to_delegates(self, mock_client): key = ResourceAttributeKey._from_proto(_key_proto()) key._apply_client_to_instance(mock_client) - mock_client.access_control.resource_attributes.assign.return_value = ["sentinel"] + mock_client.access_control.resource_attributes.assignments.create.return_value = [ + "sentinel" + ] result = key.assign_to(["ch1"], value=["LIC_A"]) - mock_client.access_control.resource_attributes.assign.assert_called_once_with( + mock_client.access_control.resource_attributes.assignments.create.assert_called_once_with( key, ["ch1"], value=["LIC_A"] ) assert result == ["sentinel"] @@ -160,7 +162,7 @@ def test_assign_to_delegates(self, mock_client): def test_check_archive_impact_delegates(self, mock_client): key = ResourceAttributeKey._from_proto(_key_proto()) key._apply_client_to_instance(mock_client) - mock_client.access_control.resource_attributes.check_key_archive_impact.return_value = 7 + mock_client.access_control.resource_attributes.keys.check_archive_impact.return_value = 7 assert key.check_archive_impact() == 7 @@ -179,11 +181,11 @@ def test_archive_returns_migrated_count(self, mock_client): ) value = ResourceAttributeEnumValue._from_proto(proto) value._apply_client_to_instance(mock_client) - mock_client.access_control.resource_attributes.archive_enum_value.return_value = 3 + mock_client.access_control.resource_attributes.enum_values.archive.return_value = 3 migrated = value.archive(replacement="ev2") - mock_client.access_control.resource_attributes.archive_enum_value.assert_called_once_with( + mock_client.access_control.resource_attributes.enum_values.archive.assert_called_once_with( value, replacement="ev2" ) assert migrated == 3 diff --git a/python/lib/sift_client/resources/__init__.py b/python/lib/sift_client/resources/__init__.py index 1b1655213..efa97fba3 100644 --- a/python/lib/sift_client/resources/__init__.py +++ b/python/lib/sift_client/resources/__init__.py @@ -163,8 +163,18 @@ async def main(): ) from sift_client.resources.jobs import JobsAPIAsync from sift_client.resources.ping import PingAPIAsync -from sift_client.resources.access_control.principal_attributes import PrincipalAttributesAPIAsync -from sift_client.resources.access_control.resource_attributes import ResourceAttributesAPIAsync +from sift_client.resources.access_control.principal_attributes import ( + PrincipalAttributeAssignmentsAPIAsync, + PrincipalAttributeEnumValuesAPIAsync, + PrincipalAttributeKeysAPIAsync, + PrincipalAttributesAPIAsync, +) +from sift_client.resources.access_control.resource_attributes import ( + ResourceAttributeAssignmentsAPIAsync, + ResourceAttributeEnumValuesAPIAsync, + ResourceAttributeKeysAPIAsync, + ResourceAttributesAPIAsync, +) from sift_client.resources.reports import ReportsAPIAsync, ReportTemplatesAPIAsync from sift_client.resources.rules import RulesAPIAsync from sift_client.resources.runs import RunsAPIAsync @@ -181,9 +191,15 @@ async def main(): ChannelsAPI, JobsAPI, PingAPI, + PrincipalAttributeAssignmentsAPI, + PrincipalAttributeEnumValuesAPI, + PrincipalAttributeKeysAPI, PrincipalAttributesAPI, ReportsAPI, ReportTemplatesAPI, + ResourceAttributeAssignmentsAPI, + ResourceAttributeEnumValuesAPI, + ResourceAttributeKeysAPI, ResourceAttributesAPI, RulesAPI, RunsAPI, @@ -220,12 +236,24 @@ async def main(): "JobsAPIAsync", "PingAPI", "PingAPIAsync", + "PrincipalAttributeAssignmentsAPI", + "PrincipalAttributeAssignmentsAPIAsync", + "PrincipalAttributeEnumValuesAPI", + "PrincipalAttributeEnumValuesAPIAsync", + "PrincipalAttributeKeysAPI", + "PrincipalAttributeKeysAPIAsync", "PrincipalAttributesAPI", "PrincipalAttributesAPIAsync", "ReportTemplatesAPI", "ReportTemplatesAPIAsync", "ReportsAPI", "ReportsAPIAsync", + "ResourceAttributeAssignmentsAPI", + "ResourceAttributeAssignmentsAPIAsync", + "ResourceAttributeEnumValuesAPI", + "ResourceAttributeEnumValuesAPIAsync", + "ResourceAttributeKeysAPI", + "ResourceAttributeKeysAPIAsync", "ResourceAttributesAPI", "ResourceAttributesAPIAsync", "RulesAPI", diff --git a/python/lib/sift_client/resources/access_control/principal_attributes.py b/python/lib/sift_client/resources/access_control/principal_attributes.py index 2caebf201..8136060e6 100644 --- a/python/lib/sift_client/resources/access_control/principal_attributes.py +++ b/python/lib/sift_client/resources/access_control/principal_attributes.py @@ -34,14 +34,42 @@ class PrincipalAttributesAPIAsync(ResourceBase): Principal attributes describe the users or groups an access decision applies to. A principal is the "who" in an access decision, such as a user or user group. - Create or fetch an attribute key, define enum values when the key uses them, then - assign a value to principals. User principals accept either user IDs or email - addresses; user-group principals use user-group IDs. + Create or fetch an attribute key via `keys`, define enum values via `enum_values` + when the key uses them, then assign a value to principals via `assignments`. User + principals accept either user IDs or email addresses; user-group principals use + user-group IDs. """ + keys: PrincipalAttributeKeysAPIAsync + """Nested keys API. See `PrincipalAttributeKeysAPIAsync`.""" + + enum_values: PrincipalAttributeEnumValuesAPIAsync + """Nested enum values API. See `PrincipalAttributeEnumValuesAPIAsync`.""" + + assignments: PrincipalAttributeAssignmentsAPIAsync + """Nested assignments API. See `PrincipalAttributeAssignmentsAPIAsync`.""" + def __init__(self, sift_client: SiftClient): """Initialize the PrincipalAttributesAPI. + Args: + sift_client: The Sift client to use. + """ + super().__init__(sift_client) + self.keys = PrincipalAttributeKeysAPIAsync(sift_client) + self.enum_values = PrincipalAttributeEnumValuesAPIAsync(sift_client) + self.assignments = PrincipalAttributeAssignmentsAPIAsync(sift_client) + + +class PrincipalAttributeKeysAPIAsync(ResourceBase): + """High-level API for principal attribute keys. + + Accessed as a nested resource via ``client.access_control.principal_attributes.keys``. + """ + + def __init__(self, sift_client: SiftClient): + """Initialize the PrincipalAttributeKeysAPI. + Args: sift_client: The Sift client to use. """ @@ -50,12 +78,12 @@ def __init__(self, sift_client: SiftClient): grpc_client=self.client.grpc_client ) - async def get_key(self, *, key_id: str) -> PrincipalAttributeKey: + async def get(self, *, key_id: str) -> PrincipalAttributeKey: """Get a principal attribute key by ID.""" key = await self._low_level_client.get_key(key_id) return self._apply_client_to_instance(key) - async def list_keys( + async def list_( self, *, name: str | None = None, @@ -105,14 +133,14 @@ async def list_keys( ) return self._apply_client_to_instances(keys) - async def find_key(self, **kwargs) -> PrincipalAttributeKey | None: + async def find(self, **kwargs) -> PrincipalAttributeKey | None: """Find a single key matching the query. Raises if more than one matches.""" - keys = await self.list_keys(**kwargs) + keys = await self.list_(**kwargs) if len(keys) > 1: raise ValueError(f"Multiple ({len(keys)}) principal attribute keys found for query") return keys[0] if keys else None - async def create_key( + async def create( self, display_name: str, value_type: PrincipalAttributeValueType, @@ -125,7 +153,7 @@ async def create_key( ) return self._apply_client_to_instance(key) - async def get_or_create_key( + async def get_or_create( self, display_name: str, value_type: PrincipalAttributeValueType, @@ -138,13 +166,13 @@ async def get_or_create_key( Display names are not guaranteed unique. If multiple keys share the display name, the first active match is returned. """ - existing = await self.list_keys(name=display_name, include_archived=False) + existing = await self.list_(name=display_name, include_archived=False) match = next((k for k in existing if k.display_name == display_name), None) if match is not None: return match - return await self.create_key(display_name, value_type, description=description) + return await self.create(display_name, value_type, description=description) - async def update_key( + async def update( self, key: str | PrincipalAttributeKey, update: PrincipalAttributeKeyUpdate | dict, @@ -161,26 +189,45 @@ async def update_key( updated = await self._low_level_client.update_key(update=update) return self._apply_client_to_instance(updated) - async def archive_key(self, key: str | PrincipalAttributeKey) -> PrincipalAttributeKey: + async def archive(self, key: str | PrincipalAttributeKey) -> PrincipalAttributeKey: """Archive a key. Cascades to its enum values and assignments.""" key_id = id_of(key) await self._low_level_client.archive_key(key_id) - return await self.get_key(key_id=key_id) + return await self.get(key_id=key_id) - async def unarchive_key(self, key: str | PrincipalAttributeKey) -> PrincipalAttributeKey: + async def unarchive(self, key: str | PrincipalAttributeKey) -> PrincipalAttributeKey: """Unarchive a key. Does not restore its cascaded enum values or assignments.""" key_id = id_of(key) await self._low_level_client.unarchive_key(key_id) - return await self.get_key(key_id=key_id) + return await self.get(key_id=key_id) - async def check_key_archive_impact(self, key: str | PrincipalAttributeKey) -> int: + async def check_archive_impact(self, key: str | PrincipalAttributeKey) -> int: """Return the number of active assignments archiving this key would affect. Counts both user and user-group assignments. """ return await self._low_level_client.check_key_archive_impact(id_of(key)) - async def create_enum_value( + +class PrincipalAttributeEnumValuesAPIAsync(ResourceBase): + """High-level API for the enum values defined on principal attribute keys. + + Accessed as a nested resource via + ``client.access_control.principal_attributes.enum_values``. + """ + + def __init__(self, sift_client: SiftClient): + """Initialize the PrincipalAttributeEnumValuesAPI. + + Args: + sift_client: The Sift client to use. + """ + super().__init__(sift_client) + self._low_level_client = PrincipalAttributesLowLevelClient( + grpc_client=self.client.grpc_client + ) + + async def create( self, key: str | PrincipalAttributeKey, display_name: str, @@ -194,7 +241,7 @@ async def create_enum_value( ) return self._apply_client_to_instance(value) - async def list_enum_values( + async def list_( self, key: str | PrincipalAttributeKey, *, @@ -225,7 +272,7 @@ async def list_enum_values( ) return self._apply_client_to_instances(values) - async def get_or_create_enum_values( + async def get_or_create( self, key: str | PrincipalAttributeKey, names: list[str] ) -> list[PrincipalAttributeEnumValue]: """Get enum values for a key by name, creating any that don't exist. @@ -233,18 +280,18 @@ async def get_or_create_enum_values( Returns the values in the same order as ``names``. """ key_id = id_of(key) - existing = await self.list_enum_values(key_id, include_archived=False) + existing = await self.list_(key_id, include_archived=False) by_name = {v.display_name: v for v in existing} result: list[PrincipalAttributeEnumValue] = [] for name in names: value = by_name.get(name) if value is None: - value = await self.create_enum_value(key_id, name) + value = await self.create(key_id, name) by_name[name] = value result.append(value) return result - async def archive_enum_value( + async def archive( self, enum_value: str | PrincipalAttributeEnumValue, *, @@ -260,7 +307,7 @@ async def archive_enum_value( enum_value_id, replacement_enum_value_id=replacement_id ) - async def unarchive_enum_value( + async def unarchive( self, enum_value: str | PrincipalAttributeEnumValue ) -> PrincipalAttributeEnumValue: """Unarchive an enum value.""" @@ -269,7 +316,26 @@ async def unarchive_enum_value( value = await self._low_level_client.get_enum_value(enum_value_id) return self._apply_client_to_instance(value) - async def assign( + +class PrincipalAttributeAssignmentsAPIAsync(ResourceBase): + """High-level API for principal attribute assignments. + + Accessed as a nested resource via + ``client.access_control.principal_attributes.assignments``. + """ + + def __init__(self, sift_client: SiftClient): + """Initialize the PrincipalAttributeAssignmentsAPI. + + Args: + sift_client: The Sift client to use. + """ + super().__init__(sift_client) + self._low_level_client = PrincipalAttributesLowLevelClient( + grpc_client=self.client.grpc_client + ) + + async def create( self, key: str | PrincipalAttributeKey, principals: list[str], @@ -293,7 +359,9 @@ async def assign( The created assignments. """ resolved_key = await resolve_key( - key, key_cls=PrincipalAttributeKey, getter=lambda key_id: self.get_key(key_id=key_id) + key, + key_cls=PrincipalAttributeKey, + getter=lambda key_id: self._low_level_client.get_key(key_id), ) resolved_ids = await self._resolve_principal_ids(principals, principal_type=principal_type) create_kwargs = attribute_value_kwargs(resolved_key.value_type, value) @@ -306,7 +374,7 @@ async def assign( ) return self._apply_client_to_instances(created) - async def get_assignment( + async def get( self, *, assignment_id: str, @@ -318,7 +386,7 @@ async def get_assignment( ) return self._apply_client_to_instance(value) - async def list_assignments( + async def list_( self, *, key: str | PrincipalAttributeKey | None = None, @@ -374,7 +442,7 @@ async def list_assignments( ) return self._apply_client_to_instances(values) - async def archive_assignments( + async def archive( self, assignments: list[str | PrincipalAttributeAssignment], *, @@ -384,7 +452,7 @@ async def archive_assignments( ids = [id_of(a) for a in assignments] await self._low_level_client.archive_values(ids, principal_type=principal_type.value) - async def unarchive_assignments( + async def unarchive( self, assignments: list[str | PrincipalAttributeAssignment], *, diff --git a/python/lib/sift_client/resources/access_control/resource_attributes.py b/python/lib/sift_client/resources/access_control/resource_attributes.py index 2e8d0f3c2..1b12d66c5 100644 --- a/python/lib/sift_client/resources/access_control/resource_attributes.py +++ b/python/lib/sift_client/resources/access_control/resource_attributes.py @@ -70,11 +70,21 @@ class ResourceAttributesAPIAsync(ResourceBase): Resource attributes describe the Sift objects an access decision applies to. A resource is the "what" in an access decision. - Create or fetch an attribute key, define enum values when the key uses them, then - assign a value to resources. For currently supported resource types, you can pass - existing ``Asset``, ``Channel``, and ``Run`` objects or their IDs directly. + Create or fetch an attribute key via `keys`, define enum values via `enum_values` + when the key uses them, then assign a value to resources via `assignments`. For + currently supported resource types, you can pass existing ``Asset``, ``Channel``, + and ``Run`` objects or their IDs directly. """ + keys: ResourceAttributeKeysAPIAsync + """Nested keys API. See `ResourceAttributeKeysAPIAsync`.""" + + enum_values: ResourceAttributeEnumValuesAPIAsync + """Nested enum values API. See `ResourceAttributeEnumValuesAPIAsync`.""" + + assignments: ResourceAttributeAssignmentsAPIAsync + """Nested assignments API. See `ResourceAttributeAssignmentsAPIAsync`.""" + def __init__(self, sift_client: SiftClient): """Initialize the ResourceAttributesAPI. @@ -82,101 +92,34 @@ def __init__(self, sift_client: SiftClient): sift_client: The Sift client to use. """ super().__init__(sift_client) - self._low_level_client = ResourceAttributesLowLevelClient( - grpc_client=self.client.grpc_client - ) - - async def _resolve_resource(self, resource: ResourceLike) -> ResourceAttributeEntity: - return (await self._resolve_resources([resource]))[0] - - async def _resolve_resources( - self, resources: list[ResourceAttributeEntity | Asset | Channel | Run | str] - ) -> list[ResourceAttributeEntity]: - resolved: list[ResourceAttributeEntity | str] = [] - resource_ids: list[str] = [] - for resource in resources: - if isinstance(resource, str): - if not resource: - raise ValueError("Resource ID cannot be empty.") - resolved.append(resource) - resource_ids.append(resource) - else: - resolved.append(_resolve_resource_object(resource)) - - if not resource_ids: - return [ - resource for resource in resolved if isinstance(resource, ResourceAttributeEntity) - ] + self.keys = ResourceAttributeKeysAPIAsync(sift_client) + self.enum_values = ResourceAttributeEnumValuesAPIAsync(sift_client) + self.assignments = ResourceAttributeAssignmentsAPIAsync(sift_client) - resources_by_id = await self._resolve_resource_ids(resource_ids) - return [ - resources_by_id[resource] if isinstance(resource, str) else resource - for resource in resolved - ] - async def _resolve_resource_ids( - self, resource_ids: list[str] - ) -> dict[str, ResourceAttributeEntity]: - unique_ids = list(dict.fromkeys(resource_ids)) - matched_types: dict[str, set[ResourceAttributeEntityType]] = {} - for batch in chunked(unique_ids, _RESOLVE_BATCH_SIZE): - assets: list[Asset] - channels: list[Channel] - runs: list[Run] - assets, channels, runs = await asyncio.gather( - self.client.async_.assets.list_(asset_ids=batch, include_archived=True), - self.client.async_.channels.list_(channel_ids=batch), - self.client.async_.runs.list_(run_ids=batch, include_archived=True), - ) - for asset in assets: - matched_types.setdefault(asset._id_or_error, set()).add( - ResourceAttributeEntityType.ASSET - ) - for channel in channels: - matched_types.setdefault(channel._id_or_error, set()).add( - ResourceAttributeEntityType.CHANNEL - ) - for run in runs: - matched_types.setdefault(run._id_or_error, set()).add( - ResourceAttributeEntityType.RUN - ) +class ResourceAttributeKeysAPIAsync(ResourceBase): + """High-level API for resource attribute keys. - ambiguous_ids = [ - resource_id - for resource_id, entity_types in matched_types.items() - if len(entity_types) > 1 - ] - if ambiguous_ids: - raise ValueError( - f"Resource ID {ambiguous_ids[0]!r} matched multiple currently supported " - "resource types." - ) + Accessed as a nested resource via ``client.access_control.resource_attributes.keys``. + """ - missing_ids = [ - resource_id for resource_id in unique_ids if resource_id not in matched_types - ] - if missing_ids: - preview = ", ".join(repr(resource_id) for resource_id in missing_ids[:3]) - if len(missing_ids) > 3: - preview = f"{preview}, and {len(missing_ids) - 3} more" - raise ValueError( - f"Could not resolve resource ID(s) {preview} as currently supported resources " - "(asset, channel, or run)." - ) + def __init__(self, sift_client: SiftClient): + """Initialize the ResourceAttributeKeysAPI. - return { - resource_id: ResourceAttributeEntity( - entity_id=resource_id, entity_type=next(iter(entity_types)) - ) - for resource_id, entity_types in matched_types.items() - } + Args: + sift_client: The Sift client to use. + """ + super().__init__(sift_client) + self._low_level_client = ResourceAttributesLowLevelClient( + grpc_client=self.client.grpc_client + ) - async def get_key(self, *, key_id: str) -> ResourceAttributeKey: + async def get(self, *, key_id: str) -> ResourceAttributeKey: """Get a resource attribute key by ID.""" key = await self._low_level_client.get_key(key_id) return self._apply_client_to_instance(key) - async def list_keys( + async def list_( self, *, name: str | None = None, @@ -226,14 +169,14 @@ async def list_keys( ) return self._apply_client_to_instances(keys) - async def find_key(self, **kwargs) -> ResourceAttributeKey | None: + async def find(self, **kwargs) -> ResourceAttributeKey | None: """Find a single key matching the query. Raises if more than one matches.""" - keys = await self.list_keys(**kwargs) + keys = await self.list_(**kwargs) if len(keys) > 1: raise ValueError(f"Multiple ({len(keys)}) resource attribute keys found for query") return keys[0] if keys else None - async def create_key( + async def create( self, display_name: str, value_type: ResourceAttributeValueType, @@ -255,7 +198,7 @@ async def create_key( ) return self._apply_client_to_instance(key) - async def get_or_create_key( + async def get_or_create( self, display_name: str, value_type: ResourceAttributeValueType, @@ -268,13 +211,13 @@ async def get_or_create_key( Display names are not guaranteed unique. If multiple keys share the display name, the first active match is returned. """ - existing = await self.list_keys(name=display_name, include_archived=False) + existing = await self.list_(name=display_name, include_archived=False) match = next((k for k in existing if k.display_name == display_name), None) if match is not None: return match - return await self.create_key(display_name, value_type, description=description) + return await self.create(display_name, value_type, description=description) - async def update_key( + async def update( self, key: str | ResourceAttributeKey, update: ResourceAttributeKeyUpdate | dict, @@ -291,23 +234,42 @@ async def update_key( updated = await self._low_level_client.update_key(update=update) return self._apply_client_to_instance(updated) - async def archive_key(self, key: str | ResourceAttributeKey) -> ResourceAttributeKey: + async def archive(self, key: str | ResourceAttributeKey) -> ResourceAttributeKey: """Archive a key. Cascades to its enum values and assignments.""" key_id = id_of(key) await self._low_level_client.archive_key(key_id) - return await self.get_key(key_id=key_id) + return await self.get(key_id=key_id) - async def unarchive_key(self, key: str | ResourceAttributeKey) -> ResourceAttributeKey: + async def unarchive(self, key: str | ResourceAttributeKey) -> ResourceAttributeKey: """Unarchive a key. Does not restore its cascaded enum values or assignments.""" key_id = id_of(key) await self._low_level_client.unarchive_key(key_id) - return await self.get_key(key_id=key_id) + return await self.get(key_id=key_id) - async def check_key_archive_impact(self, key: str | ResourceAttributeKey) -> int: + async def check_archive_impact(self, key: str | ResourceAttributeKey) -> int: """Return the number of active assignments archiving this key would affect.""" return await self._low_level_client.check_key_archive_impact(id_of(key)) - async def create_enum_value( + +class ResourceAttributeEnumValuesAPIAsync(ResourceBase): + """High-level API for the enum values defined on resource attribute keys. + + Accessed as a nested resource via + ``client.access_control.resource_attributes.enum_values``. + """ + + def __init__(self, sift_client: SiftClient): + """Initialize the ResourceAttributeEnumValuesAPI. + + Args: + sift_client: The Sift client to use. + """ + super().__init__(sift_client) + self._low_level_client = ResourceAttributesLowLevelClient( + grpc_client=self.client.grpc_client + ) + + async def create( self, key: str | ResourceAttributeKey, display_name: str, @@ -321,7 +283,7 @@ async def create_enum_value( ) return self._apply_client_to_instance(value) - async def list_enum_values( + async def list_( self, key: str | ResourceAttributeKey, *, @@ -352,7 +314,7 @@ async def list_enum_values( ) return self._apply_client_to_instances(values) - async def get_or_create_enum_values( + async def get_or_create( self, key: str | ResourceAttributeKey, names: list[str] ) -> list[ResourceAttributeEnumValue]: """Get enum values for a key by name, creating any that don't exist. @@ -360,18 +322,18 @@ async def get_or_create_enum_values( Returns the values in the same order as ``names``. """ key_id = id_of(key) - existing = await self.list_enum_values(key_id, include_archived=False) + existing = await self.list_(key_id, include_archived=False) by_name = {v.display_name: v for v in existing} result: list[ResourceAttributeEnumValue] = [] for name in names: value = by_name.get(name) if value is None: - value = await self.create_enum_value(key_id, name) + value = await self.create(key_id, name) by_name[name] = value result.append(value) return result - async def archive_enum_value( + async def archive( self, enum_value: str | ResourceAttributeEnumValue, *, @@ -387,7 +349,7 @@ async def archive_enum_value( enum_value_id, replacement_enum_value_id=replacement_id ) - async def unarchive_enum_value( + async def unarchive( self, enum_value: str | ResourceAttributeEnumValue ) -> ResourceAttributeEnumValue: """Unarchive an enum value.""" @@ -396,7 +358,26 @@ async def unarchive_enum_value( value = await self._low_level_client.get_enum_value(enum_value_id) return self._apply_client_to_instance(value) - async def assign( + +class ResourceAttributeAssignmentsAPIAsync(ResourceBase): + """High-level API for resource attribute assignments. + + Accessed as a nested resource via + ``client.access_control.resource_attributes.assignments``. + """ + + def __init__(self, sift_client: SiftClient): + """Initialize the ResourceAttributeAssignmentsAPI. + + Args: + sift_client: The Sift client to use. + """ + super().__init__(sift_client) + self._low_level_client = ResourceAttributesLowLevelClient( + grpc_client=self.client.grpc_client + ) + + async def create( self, key: str | ResourceAttributeKey, resources: list[ResourceAttributeEntity | Asset | Channel | Run | str], @@ -418,7 +399,9 @@ async def assign( The created assignments. """ resolved_key = await resolve_key( - key, key_cls=ResourceAttributeKey, getter=lambda key_id: self.get_key(key_id=key_id) + key, + key_cls=ResourceAttributeKey, + getter=lambda key_id: self._low_level_client.get_key(key_id), ) resolved = await self._resolve_resources(resources) create_kwargs = attribute_value_kwargs(resolved_key.value_type, value) @@ -428,12 +411,12 @@ async def assign( ) return self._apply_client_to_instances(created) - async def get_assignment(self, *, assignment_id: str) -> ResourceAttributeAssignment: + async def get(self, *, assignment_id: str) -> ResourceAttributeAssignment: """Get a single assignment by ID.""" attr = await self._low_level_client.get_resource_attribute(assignment_id) return self._apply_client_to_instance(attr) - async def list_assignments( + async def list_( self, *, key: str | ResourceAttributeKey | None = None, @@ -481,16 +464,97 @@ async def list_assignments( ) return self._apply_client_to_instances(attrs) - async def archive_assignments( - self, assignments: list[str | ResourceAttributeAssignment] - ) -> None: + async def archive(self, assignments: list[str | ResourceAttributeAssignment]) -> None: """Batch archive assignments.""" ids = [id_of(a) for a in assignments] await self._low_level_client.batch_archive_resource_attributes(ids) - async def unarchive_assignments( - self, assignments: list[str | ResourceAttributeAssignment] - ) -> None: + async def unarchive(self, assignments: list[str | ResourceAttributeAssignment]) -> None: """Batch unarchive assignments.""" ids = [id_of(a) for a in assignments] await self._low_level_client.batch_unarchive_resource_attributes(ids) + + async def _resolve_resource(self, resource: ResourceLike) -> ResourceAttributeEntity: + return (await self._resolve_resources([resource]))[0] + + async def _resolve_resources( + self, resources: list[ResourceAttributeEntity | Asset | Channel | Run | str] + ) -> list[ResourceAttributeEntity]: + resolved: list[ResourceAttributeEntity | str] = [] + resource_ids: list[str] = [] + for resource in resources: + if isinstance(resource, str): + if not resource: + raise ValueError("Resource ID cannot be empty.") + resolved.append(resource) + resource_ids.append(resource) + else: + resolved.append(_resolve_resource_object(resource)) + + if not resource_ids: + return [ + resource for resource in resolved if isinstance(resource, ResourceAttributeEntity) + ] + + resources_by_id = await self._resolve_resource_ids(resource_ids) + return [ + resources_by_id[resource] if isinstance(resource, str) else resource + for resource in resolved + ] + + async def _resolve_resource_ids( + self, resource_ids: list[str] + ) -> dict[str, ResourceAttributeEntity]: + unique_ids = list(dict.fromkeys(resource_ids)) + matched_types: dict[str, set[ResourceAttributeEntityType]] = {} + for batch in chunked(unique_ids, _RESOLVE_BATCH_SIZE): + assets: list[Asset] + channels: list[Channel] + runs: list[Run] + assets, channels, runs = await asyncio.gather( + self.client.async_.assets.list_(asset_ids=batch, include_archived=True), + self.client.async_.channels.list_(channel_ids=batch), + self.client.async_.runs.list_(run_ids=batch, include_archived=True), + ) + for asset in assets: + matched_types.setdefault(asset._id_or_error, set()).add( + ResourceAttributeEntityType.ASSET + ) + for channel in channels: + matched_types.setdefault(channel._id_or_error, set()).add( + ResourceAttributeEntityType.CHANNEL + ) + for run in runs: + matched_types.setdefault(run._id_or_error, set()).add( + ResourceAttributeEntityType.RUN + ) + + ambiguous_ids = [ + resource_id + for resource_id, entity_types in matched_types.items() + if len(entity_types) > 1 + ] + if ambiguous_ids: + raise ValueError( + f"Resource ID {ambiguous_ids[0]!r} matched multiple currently supported " + "resource types." + ) + + missing_ids = [ + resource_id for resource_id in unique_ids if resource_id not in matched_types + ] + if missing_ids: + preview = ", ".join(repr(resource_id) for resource_id in missing_ids[:3]) + if len(missing_ids) > 3: + preview = f"{preview}, and {len(missing_ids) - 3} more" + raise ValueError( + f"Could not resolve resource ID(s) {preview} as currently supported resources " + "(asset, channel, or run)." + ) + + return { + resource_id: ResourceAttributeEntity( + entity_id=resource_id, entity_type=next(iter(entity_types)) + ) + for resource_id, entity_types in matched_types.items() + } diff --git a/python/lib/sift_client/resources/sync_stubs/__init__.py b/python/lib/sift_client/resources/sync_stubs/__init__.py index b80e93187..b9544706d 100644 --- a/python/lib/sift_client/resources/sync_stubs/__init__.py +++ b/python/lib/sift_client/resources/sync_stubs/__init__.py @@ -12,9 +12,15 @@ FileAttachmentsAPIAsync, JobsAPIAsync, PingAPIAsync, + PrincipalAttributeAssignmentsAPIAsync, + PrincipalAttributeEnumValuesAPIAsync, + PrincipalAttributeKeysAPIAsync, PrincipalAttributesAPIAsync, ReportsAPIAsync, ReportTemplatesAPIAsync, + ResourceAttributeAssignmentsAPIAsync, + ResourceAttributeEnumValuesAPIAsync, + ResourceAttributeKeysAPIAsync, ResourceAttributesAPIAsync, RulesAPIAsync, RunsAPIAsync, @@ -36,8 +42,44 @@ ReportsAPI = generate_sync_api( ReportsAPIAsync, "ReportsAPI", nested_resources={"templates": ReportTemplatesAPI} ) -ResourceAttributesAPI = generate_sync_api(ResourceAttributesAPIAsync, "ResourceAttributesAPI") -PrincipalAttributesAPI = generate_sync_api(PrincipalAttributesAPIAsync, "PrincipalAttributesAPI") +# The attribute sub-resource APIs must be generated before their parents so they +# can be nested under them. +ResourceAttributeKeysAPI = generate_sync_api( + ResourceAttributeKeysAPIAsync, "ResourceAttributeKeysAPI" +) +ResourceAttributeEnumValuesAPI = generate_sync_api( + ResourceAttributeEnumValuesAPIAsync, "ResourceAttributeEnumValuesAPI" +) +ResourceAttributeAssignmentsAPI = generate_sync_api( + ResourceAttributeAssignmentsAPIAsync, "ResourceAttributeAssignmentsAPI" +) +ResourceAttributesAPI = generate_sync_api( + ResourceAttributesAPIAsync, + "ResourceAttributesAPI", + nested_resources={ + "keys": ResourceAttributeKeysAPI, + "enum_values": ResourceAttributeEnumValuesAPI, + "assignments": ResourceAttributeAssignmentsAPI, + }, +) +PrincipalAttributeKeysAPI = generate_sync_api( + PrincipalAttributeKeysAPIAsync, "PrincipalAttributeKeysAPI" +) +PrincipalAttributeEnumValuesAPI = generate_sync_api( + PrincipalAttributeEnumValuesAPIAsync, "PrincipalAttributeEnumValuesAPI" +) +PrincipalAttributeAssignmentsAPI = generate_sync_api( + PrincipalAttributeAssignmentsAPIAsync, "PrincipalAttributeAssignmentsAPI" +) +PrincipalAttributesAPI = generate_sync_api( + PrincipalAttributesAPIAsync, + "PrincipalAttributesAPI", + nested_resources={ + "keys": PrincipalAttributeKeysAPI, + "enum_values": PrincipalAttributeEnumValuesAPI, + "assignments": PrincipalAttributeAssignmentsAPI, + }, +) TagsAPI = generate_sync_api(TagsAPIAsync, "TagsAPI") TestResultsAPI = generate_sync_api(TestResultsAPIAsync, "TestResultsAPI") UsersAPI = generate_sync_api(UsersAPIAsync, "UsersAPI") @@ -53,9 +95,15 @@ "FileAttachmentsAPI", "JobsAPI", "PingAPI", + "PrincipalAttributeAssignmentsAPI", + "PrincipalAttributeEnumValuesAPI", + "PrincipalAttributeKeysAPI", "PrincipalAttributesAPI", "ReportTemplatesAPI", "ReportsAPI", + "ResourceAttributeAssignmentsAPI", + "ResourceAttributeEnumValuesAPI", + "ResourceAttributeKeysAPI", "ResourceAttributesAPI", "RulesAPI", "RunsAPI", diff --git a/python/lib/sift_client/resources/sync_stubs/__init__.pyi b/python/lib/sift_client/resources/sync_stubs/__init__.pyi index 1130039a9..d4dec68db 100644 --- a/python/lib/sift_client/resources/sync_stubs/__init__.pyi +++ b/python/lib/sift_client/resources/sync_stubs/__init__.pyi @@ -1185,21 +1185,17 @@ class PingAPI: """ ... -class PrincipalAttributesAPI: - """Sync counterpart to `PrincipalAttributesAPIAsync`. +class PrincipalAttributeAssignmentsAPI: + """Sync counterpart to `PrincipalAttributeAssignmentsAPIAsync`. - High-level API for principal attributes. + High-level API for principal attribute assignments. - Principal attributes describe the users or groups an access decision applies to. - A principal is the "who" in an access decision, such as a user or user group. - - Create or fetch an attribute key, define enum values when the key uses them, then - assign a value to principals. User principals accept either user IDs or email - addresses; user-group principals use user-group IDs. + Accessed as a nested resource via + ``client.access_control.principal_attributes.assignments``. """ def __init__(self, sift_client: SiftClient): - """Initialize the PrincipalAttributesAPI. + """Initialize the PrincipalAttributeAssignmentsAPI. Args: sift_client: The Sift client to use. @@ -1207,7 +1203,7 @@ class PrincipalAttributesAPI: ... def _run(self, coro): ... - def archive_assignments( + def archive( self, assignments: list[str | PrincipalAttributeAssignment], *, @@ -1216,23 +1212,7 @@ class PrincipalAttributesAPI: """Batch archive assignments.""" ... - def archive_enum_value( - self, - enum_value: str | PrincipalAttributeEnumValue, - *, - replacement: str | PrincipalAttributeEnumValue | None = None, - ) -> int: - """Archive an enum value, migrating existing assignments to a replacement. - - Returns the number of assignments migrated. - """ - ... - - def archive_key(self, key: str | PrincipalAttributeKey) -> PrincipalAttributeKey: - """Archive a key. Cascades to its enum values and assignments.""" - ... - - def assign( + def create( self, key: str | PrincipalAttributeKey, principals: list[str], @@ -1257,60 +1237,13 @@ class PrincipalAttributesAPI: """ ... - def check_key_archive_impact(self, key: str | PrincipalAttributeKey) -> int: - """Return the number of active assignments archiving this key would affect. - - Counts both user and user-group assignments. - """ - ... - - def create_enum_value( - self, key: str | PrincipalAttributeKey, display_name: str, *, description: str = "" - ) -> PrincipalAttributeEnumValue: - """Create a single enum value for a key.""" - ... - - def create_key( - self, display_name: str, value_type: PrincipalAttributeValueType, *, description: str = "" - ) -> PrincipalAttributeKey: - """Create a principal attribute key.""" - ... - - def find_key(self, **kwargs) -> PrincipalAttributeKey | None: - """Find a single key matching the query. Raises if more than one matches.""" - ... - - def get_assignment( + def get( self, *, assignment_id: str, principal_type: PrincipalType = PrincipalType.USER ) -> PrincipalAttributeAssignment: """Get a single assignment by ID.""" ... - def get_key(self, *, key_id: str) -> PrincipalAttributeKey: - """Get a principal attribute key by ID.""" - ... - - def get_or_create_enum_values( - self, key: str | PrincipalAttributeKey, names: list[str] - ) -> list[PrincipalAttributeEnumValue]: - """Get enum values for a key by name, creating any that don't exist. - - Returns the values in the same order as ``names``. - """ - ... - - def get_or_create_key( - self, display_name: str, value_type: PrincipalAttributeValueType, *, description: str = "" - ) -> PrincipalAttributeKey: - """Get a key by display name, creating it if it does not exist. - - Note: - Display names are not guaranteed unique. If multiple keys share the display - name, the first active match is returned. - """ - ... - - def list_assignments( + def list_( self, *, key: str | PrincipalAttributeKey | None = None, @@ -1337,7 +1270,61 @@ class PrincipalAttributesAPI: """ ... - def list_enum_values( + def unarchive( + self, + assignments: list[str | PrincipalAttributeAssignment], + *, + principal_type: PrincipalType = PrincipalType.USER, + ) -> None: + """Batch unarchive assignments.""" + ... + +class PrincipalAttributeEnumValuesAPI: + """Sync counterpart to `PrincipalAttributeEnumValuesAPIAsync`. + + High-level API for the enum values defined on principal attribute keys. + + Accessed as a nested resource via + ``client.access_control.principal_attributes.enum_values``. + """ + + def __init__(self, sift_client: SiftClient): + """Initialize the PrincipalAttributeEnumValuesAPI. + + Args: + sift_client: The Sift client to use. + """ + ... + + def _run(self, coro): ... + def archive( + self, + enum_value: str | PrincipalAttributeEnumValue, + *, + replacement: str | PrincipalAttributeEnumValue | None = None, + ) -> int: + """Archive an enum value, migrating existing assignments to a replacement. + + Returns the number of assignments migrated. + """ + ... + + def create( + self, key: str | PrincipalAttributeKey, display_name: str, *, description: str = "" + ) -> PrincipalAttributeEnumValue: + """Create a single enum value for a key.""" + ... + + def get_or_create( + self, key: str | PrincipalAttributeKey, names: list[str] + ) -> list[PrincipalAttributeEnumValue]: + """Get enum values for a key by name, creating any that don't exist. + + Returns the values in the same order as ``names``. + """ + ... + + def list_( self, key: str | PrincipalAttributeKey, *, @@ -1354,7 +1341,66 @@ class PrincipalAttributesAPI: """List the enum values defined for a key.""" ... - def list_keys( + def unarchive( + self, enum_value: str | PrincipalAttributeEnumValue + ) -> PrincipalAttributeEnumValue: + """Unarchive an enum value.""" + ... + +class PrincipalAttributeKeysAPI: + """Sync counterpart to `PrincipalAttributeKeysAPIAsync`. + + High-level API for principal attribute keys. + + Accessed as a nested resource via ``client.access_control.principal_attributes.keys``. + """ + + def __init__(self, sift_client: SiftClient): + """Initialize the PrincipalAttributeKeysAPI. + + Args: + sift_client: The Sift client to use. + """ + ... + + def _run(self, coro): ... + def archive(self, key: str | PrincipalAttributeKey) -> PrincipalAttributeKey: + """Archive a key. Cascades to its enum values and assignments.""" + ... + + def check_archive_impact(self, key: str | PrincipalAttributeKey) -> int: + """Return the number of active assignments archiving this key would affect. + + Counts both user and user-group assignments. + """ + ... + + def create( + self, display_name: str, value_type: PrincipalAttributeValueType, *, description: str = "" + ) -> PrincipalAttributeKey: + """Create a principal attribute key.""" + ... + + def find(self, **kwargs) -> PrincipalAttributeKey | None: + """Find a single key matching the query. Raises if more than one matches.""" + ... + + def get(self, *, key_id: str) -> PrincipalAttributeKey: + """Get a principal attribute key by ID.""" + ... + + def get_or_create( + self, display_name: str, value_type: PrincipalAttributeValueType, *, description: str = "" + ) -> PrincipalAttributeKey: + """Get a key by display name, creating it if it does not exist. + + Note: + Display names are not guaranteed unique. If multiple keys share the display + name, the first active match is returned. + """ + ... + + def list_( self, *, name: str | None = None, @@ -1384,26 +1430,11 @@ class PrincipalAttributesAPI: """ ... - def unarchive_assignments( - self, - assignments: list[str | PrincipalAttributeAssignment], - *, - principal_type: PrincipalType = PrincipalType.USER, - ) -> None: - """Batch unarchive assignments.""" - ... - - def unarchive_enum_value( - self, enum_value: str | PrincipalAttributeEnumValue - ) -> PrincipalAttributeEnumValue: - """Unarchive an enum value.""" - ... - - def unarchive_key(self, key: str | PrincipalAttributeKey) -> PrincipalAttributeKey: + def unarchive(self, key: str | PrincipalAttributeKey) -> PrincipalAttributeKey: """Unarchive a key. Does not restore its cascaded enum values or assignments.""" ... - def update_key( + def update( self, key: str | PrincipalAttributeKey, update: PrincipalAttributeKeyUpdate | dict ) -> PrincipalAttributeKey: """Update a key. @@ -1414,6 +1445,42 @@ class PrincipalAttributesAPI: """ ... +class PrincipalAttributesAPI: + """Sync counterpart to `PrincipalAttributesAPIAsync`. + + High-level API for principal attributes. + + Principal attributes describe the users or groups an access decision applies to. + A principal is the "who" in an access decision, such as a user or user group. + + Create or fetch an attribute key via `keys`, define enum values via `enum_values` + when the key uses them, then assign a value to principals via `assignments`. User + principals accept either user IDs or email addresses; user-group principals use + user-group IDs. + """ + + def __init__(self, sift_client: SiftClient): + """Initialize the PrincipalAttributesAPI. + + Args: + sift_client: The Sift client to use. + """ + ... + + def _run(self, coro): ... + @property + def assignments(self) -> PrincipalAttributeAssignmentsAPI: + """Nested PrincipalAttributeAssignmentsAPI for making synchronous requests.""" + ... + @property + def enum_values(self) -> PrincipalAttributeEnumValuesAPI: + """Nested PrincipalAttributeEnumValuesAPI for making synchronous requests.""" + ... + @property + def keys(self) -> PrincipalAttributeKeysAPI: + """Nested PrincipalAttributeKeysAPI for making synchronous requests.""" + ... + class ReportTemplatesAPI: """Sync counterpart to `ReportTemplatesAPIAsync`. @@ -1804,21 +1871,17 @@ class ReportsAPI: """Nested ReportTemplatesAPI for making synchronous requests.""" ... -class ResourceAttributesAPI: - """Sync counterpart to `ResourceAttributesAPIAsync`. +class ResourceAttributeAssignmentsAPI: + """Sync counterpart to `ResourceAttributeAssignmentsAPIAsync`. - High-level API for resource attributes. - - Resource attributes describe the Sift objects an access decision applies to. A - resource is the "what" in an access decision. + High-level API for resource attribute assignments. - Create or fetch an attribute key, define enum values when the key uses them, then - assign a value to resources. For currently supported resource types, you can pass - existing ``Asset``, ``Channel``, and ``Run`` objects or their IDs directly. + Accessed as a nested resource via + ``client.access_control.resource_attributes.assignments``. """ def __init__(self, sift_client: SiftClient): - """Initialize the ResourceAttributesAPI. + """Initialize the ResourceAttributeAssignmentsAPI. Args: sift_client: The Sift client to use. @@ -1826,27 +1889,11 @@ class ResourceAttributesAPI: ... def _run(self, coro): ... - def archive_assignments(self, assignments: list[str | ResourceAttributeAssignment]) -> None: + def archive(self, assignments: list[str | ResourceAttributeAssignment]) -> None: """Batch archive assignments.""" ... - def archive_enum_value( - self, - enum_value: str | ResourceAttributeEnumValue, - *, - replacement: str | ResourceAttributeEnumValue | None = None, - ) -> int: - """Archive an enum value, migrating existing assignments to a replacement. - - Returns the number of assignments migrated. - """ - ... - - def archive_key(self, key: str | ResourceAttributeKey) -> ResourceAttributeKey: - """Archive a key. Cascades to its enum values and assignments.""" - ... - - def assign( + def create( self, key: str | ResourceAttributeKey, resources: list[ResourceAttributeEntity | Asset | Channel | Run | str], @@ -1869,64 +1916,11 @@ class ResourceAttributesAPI: """ ... - def check_key_archive_impact(self, key: str | ResourceAttributeKey) -> int: - """Return the number of active assignments archiving this key would affect.""" - ... - - def create_enum_value( - self, key: str | ResourceAttributeKey, display_name: str, *, description: str = "" - ) -> ResourceAttributeEnumValue: - """Create a single enum value for a key.""" - ... - - def create_key( - self, display_name: str, value_type: ResourceAttributeValueType, *, description: str = "" - ) -> ResourceAttributeKey: - """Create a resource attribute key. - - Args: - display_name: The human-readable name of the key. - value_type: The value type of the key. - description: Optional description. - - Returns: - The created key. - """ - ... - - def find_key(self, **kwargs) -> ResourceAttributeKey | None: - """Find a single key matching the query. Raises if more than one matches.""" - ... - - def get_assignment(self, *, assignment_id: str) -> ResourceAttributeAssignment: + def get(self, *, assignment_id: str) -> ResourceAttributeAssignment: """Get a single assignment by ID.""" ... - def get_key(self, *, key_id: str) -> ResourceAttributeKey: - """Get a resource attribute key by ID.""" - ... - - def get_or_create_enum_values( - self, key: str | ResourceAttributeKey, names: list[str] - ) -> list[ResourceAttributeEnumValue]: - """Get enum values for a key by name, creating any that don't exist. - - Returns the values in the same order as ``names``. - """ - ... - - def get_or_create_key( - self, display_name: str, value_type: ResourceAttributeValueType, *, description: str = "" - ) -> ResourceAttributeKey: - """Get a key by display name, creating it if it does not exist. - - Note: - Display names are not guaranteed unique. If multiple keys share the display - name, the first active match is returned. - """ - ... - - def list_assignments( + def list_( self, *, key: str | ResourceAttributeKey | None = None, @@ -1951,7 +1945,56 @@ class ResourceAttributesAPI: """ ... - def list_enum_values( + def unarchive(self, assignments: list[str | ResourceAttributeAssignment]) -> None: + """Batch unarchive assignments.""" + ... + +class ResourceAttributeEnumValuesAPI: + """Sync counterpart to `ResourceAttributeEnumValuesAPIAsync`. + + High-level API for the enum values defined on resource attribute keys. + + Accessed as a nested resource via + ``client.access_control.resource_attributes.enum_values``. + """ + + def __init__(self, sift_client: SiftClient): + """Initialize the ResourceAttributeEnumValuesAPI. + + Args: + sift_client: The Sift client to use. + """ + ... + + def _run(self, coro): ... + def archive( + self, + enum_value: str | ResourceAttributeEnumValue, + *, + replacement: str | ResourceAttributeEnumValue | None = None, + ) -> int: + """Archive an enum value, migrating existing assignments to a replacement. + + Returns the number of assignments migrated. + """ + ... + + def create( + self, key: str | ResourceAttributeKey, display_name: str, *, description: str = "" + ) -> ResourceAttributeEnumValue: + """Create a single enum value for a key.""" + ... + + def get_or_create( + self, key: str | ResourceAttributeKey, names: list[str] + ) -> list[ResourceAttributeEnumValue]: + """Get enum values for a key by name, creating any that don't exist. + + Returns the values in the same order as ``names``. + """ + ... + + def list_( self, key: str | ResourceAttributeKey, *, @@ -1968,7 +2011,70 @@ class ResourceAttributesAPI: """List the enum values defined for a key.""" ... - def list_keys( + def unarchive(self, enum_value: str | ResourceAttributeEnumValue) -> ResourceAttributeEnumValue: + """Unarchive an enum value.""" + ... + +class ResourceAttributeKeysAPI: + """Sync counterpart to `ResourceAttributeKeysAPIAsync`. + + High-level API for resource attribute keys. + + Accessed as a nested resource via ``client.access_control.resource_attributes.keys``. + """ + + def __init__(self, sift_client: SiftClient): + """Initialize the ResourceAttributeKeysAPI. + + Args: + sift_client: The Sift client to use. + """ + ... + + def _run(self, coro): ... + def archive(self, key: str | ResourceAttributeKey) -> ResourceAttributeKey: + """Archive a key. Cascades to its enum values and assignments.""" + ... + + def check_archive_impact(self, key: str | ResourceAttributeKey) -> int: + """Return the number of active assignments archiving this key would affect.""" + ... + + def create( + self, display_name: str, value_type: ResourceAttributeValueType, *, description: str = "" + ) -> ResourceAttributeKey: + """Create a resource attribute key. + + Args: + display_name: The human-readable name of the key. + value_type: The value type of the key. + description: Optional description. + + Returns: + The created key. + """ + ... + + def find(self, **kwargs) -> ResourceAttributeKey | None: + """Find a single key matching the query. Raises if more than one matches.""" + ... + + def get(self, *, key_id: str) -> ResourceAttributeKey: + """Get a resource attribute key by ID.""" + ... + + def get_or_create( + self, display_name: str, value_type: ResourceAttributeValueType, *, description: str = "" + ) -> ResourceAttributeKey: + """Get a key by display name, creating it if it does not exist. + + Note: + Display names are not guaranteed unique. If multiple keys share the display + name, the first active match is returned. + """ + ... + + def list_( self, *, name: str | None = None, @@ -2001,21 +2107,11 @@ class ResourceAttributesAPI: """ ... - def unarchive_assignments(self, assignments: list[str | ResourceAttributeAssignment]) -> None: - """Batch unarchive assignments.""" - ... - - def unarchive_enum_value( - self, enum_value: str | ResourceAttributeEnumValue - ) -> ResourceAttributeEnumValue: - """Unarchive an enum value.""" - ... - - def unarchive_key(self, key: str | ResourceAttributeKey) -> ResourceAttributeKey: + def unarchive(self, key: str | ResourceAttributeKey) -> ResourceAttributeKey: """Unarchive a key. Does not restore its cascaded enum values or assignments.""" ... - def update_key( + def update( self, key: str | ResourceAttributeKey, update: ResourceAttributeKeyUpdate | dict ) -> ResourceAttributeKey: """Update a key. @@ -2026,6 +2122,42 @@ class ResourceAttributesAPI: """ ... +class ResourceAttributesAPI: + """Sync counterpart to `ResourceAttributesAPIAsync`. + + High-level API for resource attributes. + + Resource attributes describe the Sift objects an access decision applies to. A + resource is the "what" in an access decision. + + Create or fetch an attribute key via `keys`, define enum values via `enum_values` + when the key uses them, then assign a value to resources via `assignments`. For + currently supported resource types, you can pass existing ``Asset``, ``Channel``, + and ``Run`` objects or their IDs directly. + """ + + def __init__(self, sift_client: SiftClient): + """Initialize the ResourceAttributesAPI. + + Args: + sift_client: The Sift client to use. + """ + ... + + def _run(self, coro): ... + @property + def assignments(self) -> ResourceAttributeAssignmentsAPI: + """Nested ResourceAttributeAssignmentsAPI for making synchronous requests.""" + ... + @property + def enum_values(self) -> ResourceAttributeEnumValuesAPI: + """Nested ResourceAttributeEnumValuesAPI for making synchronous requests.""" + ... + @property + def keys(self) -> ResourceAttributeKeysAPI: + """Nested ResourceAttributeKeysAPI for making synchronous requests.""" + ... + class RulesAPI: """Sync counterpart to `RulesAPIAsync`. diff --git a/python/lib/sift_client/sift_types/principal_attribute.py b/python/lib/sift_client/sift_types/principal_attribute.py index 55e81408b..0bccd7e2d 100644 --- a/python/lib/sift_client/sift_types/principal_attribute.py +++ b/python/lib/sift_client/sift_types/principal_attribute.py @@ -96,13 +96,13 @@ def archive(self, *, replacement: PrincipalAttributeEnumValue | str | None = Non Returns the migration count; it does not refresh this instance's ``is_archived``/``archived_date``. Re-fetch the enum value to observe those. """ - return self.client.access_control.principal_attributes.archive_enum_value( + return self.client.access_control.principal_attributes.enum_values.archive( self, replacement=replacement ) def unarchive(self) -> PrincipalAttributeEnumValue: """Unarchive this enum value.""" - updated = self.client.access_control.principal_attributes.unarchive_enum_value(self) + updated = self.client.access_control.principal_attributes.enum_values.unarchive(self) self._update(updated) return self @@ -204,11 +204,11 @@ def value(self) -> PrincipalAttributeEnumValue | str | bool | int | None: def archive(self) -> PrincipalAttributeAssignment: """Archive this assignment.""" - self.client.access_control.principal_attributes.archive_assignments( + self.client.access_control.principal_attributes.assignments.archive( [self], principal_type=self.principal_type ) self._update( - self.client.access_control.principal_attributes.get_assignment( + self.client.access_control.principal_attributes.assignments.get( assignment_id=self._id_or_error, principal_type=self.principal_type ) ) @@ -216,11 +216,11 @@ def archive(self) -> PrincipalAttributeAssignment: def unarchive(self) -> PrincipalAttributeAssignment: """Unarchive this assignment.""" - self.client.access_control.principal_attributes.unarchive_assignments( + self.client.access_control.principal_attributes.assignments.unarchive( [self], principal_type=self.principal_type ) self._update( - self.client.access_control.principal_attributes.get_assignment( + self.client.access_control.principal_attributes.assignments.get( assignment_id=self._id_or_error, principal_type=self.principal_type ) ) @@ -269,13 +269,13 @@ def create_enum_value( self, display_name: str, *, description: str = "" ) -> PrincipalAttributeEnumValue: """Create a single enum value for this key.""" - return self.client.access_control.principal_attributes.create_enum_value( + return self.client.access_control.principal_attributes.enum_values.create( self, display_name, description=description ) def get_or_create_enum_values(self, names: list[str]) -> list[PrincipalAttributeEnumValue]: """Get existing enum values by name, creating any that don't exist.""" - return self.client.access_control.principal_attributes.get_or_create_enum_values( + return self.client.access_control.principal_attributes.enum_values.get_or_create( self, names ) @@ -283,7 +283,7 @@ def list_enum_values( self, *, include_archived: bool = False ) -> list[PrincipalAttributeEnumValue]: """List the enum values defined for this key.""" - return self.client.access_control.principal_attributes.list_enum_values( + return self.client.access_control.principal_attributes.enum_values.list_( self, include_archived=include_archived ) @@ -309,7 +309,7 @@ def assign_to( Returns: The created assignments. """ - return self.client.access_control.principal_attributes.assign( + return self.client.access_control.principal_attributes.assignments.create( self, principals, value=value, principal_type=principal_type ) @@ -317,7 +317,7 @@ def list_assignments( self, *, principal_type: PrincipalType = PrincipalType.USER, include_archived: bool = False ) -> list[PrincipalAttributeAssignment]: """List all assignments of this key for the given principal type.""" - return self.client.access_control.principal_attributes.list_assignments( + return self.client.access_control.principal_attributes.assignments.list_( key=self, principal_type=principal_type, include_archived=include_archived ) @@ -327,25 +327,25 @@ def update(self, update: PrincipalAttributeKeyUpdate | dict) -> PrincipalAttribu Args: update: Either a PrincipalAttributeKeyUpdate instance or a dict of fields to update. """ - updated = self.client.access_control.principal_attributes.update_key(self, update=update) + updated = self.client.access_control.principal_attributes.keys.update(self, update=update) self._update(updated) return self def archive(self) -> PrincipalAttributeKey: """Archive this key. Cascades to its enum values and assignments.""" - updated = self.client.access_control.principal_attributes.archive_key(self) + updated = self.client.access_control.principal_attributes.keys.archive(self) self._update(updated) return self def unarchive(self) -> PrincipalAttributeKey: """Unarchive this key.""" - updated = self.client.access_control.principal_attributes.unarchive_key(self) + updated = self.client.access_control.principal_attributes.keys.unarchive(self) self._update(updated) return self def check_archive_impact(self) -> int: """Return the number of active assignments that archiving this key would affect.""" - return self.client.access_control.principal_attributes.check_key_archive_impact(self) + return self.client.access_control.principal_attributes.keys.check_archive_impact(self) def __str__(self) -> str: return self.display_name diff --git a/python/lib/sift_client/sift_types/resource_attribute.py b/python/lib/sift_client/sift_types/resource_attribute.py index 05a6e185f..d1468d9dd 100644 --- a/python/lib/sift_client/sift_types/resource_attribute.py +++ b/python/lib/sift_client/sift_types/resource_attribute.py @@ -135,13 +135,13 @@ def archive(self, *, replacement: ResourceAttributeEnumValue | str | None = None Returns the migration count; it does not refresh this instance's ``is_archived``/``archived_date``. Re-fetch the enum value to observe those. """ - return self.client.access_control.resource_attributes.archive_enum_value( + return self.client.access_control.resource_attributes.enum_values.archive( self, replacement=replacement ) def unarchive(self) -> ResourceAttributeEnumValue: """Unarchive this enum value.""" - updated = self.client.access_control.resource_attributes.unarchive_enum_value(self) + updated = self.client.access_control.resource_attributes.enum_values.unarchive(self) self._update(updated) return self @@ -243,9 +243,9 @@ def value(self) -> ResourceAttributeEnumValue | str | bool | int | None: def archive(self) -> ResourceAttributeAssignment: """Archive this assignment.""" - self.client.access_control.resource_attributes.archive_assignments([self]) + self.client.access_control.resource_attributes.assignments.archive([self]) self._update( - self.client.access_control.resource_attributes.get_assignment( + self.client.access_control.resource_attributes.assignments.get( assignment_id=self._id_or_error ) ) @@ -253,9 +253,9 @@ def archive(self) -> ResourceAttributeAssignment: def unarchive(self) -> ResourceAttributeAssignment: """Unarchive this assignment.""" - self.client.access_control.resource_attributes.unarchive_assignments([self]) + self.client.access_control.resource_attributes.assignments.unarchive([self]) self._update( - self.client.access_control.resource_attributes.get_assignment( + self.client.access_control.resource_attributes.assignments.get( assignment_id=self._id_or_error ) ) @@ -304,19 +304,19 @@ def create_enum_value( self, display_name: str, *, description: str = "" ) -> ResourceAttributeEnumValue: """Create a single enum value for this key.""" - return self.client.access_control.resource_attributes.create_enum_value( + return self.client.access_control.resource_attributes.enum_values.create( self, display_name, description=description ) def get_or_create_enum_values(self, names: list[str]) -> list[ResourceAttributeEnumValue]: """Get existing enum values by name, creating any that don't exist.""" - return self.client.access_control.resource_attributes.get_or_create_enum_values(self, names) + return self.client.access_control.resource_attributes.enum_values.get_or_create(self, names) def list_enum_values( self, *, include_archived: bool = False ) -> list[ResourceAttributeEnumValue]: """List the enum values defined for this key.""" - return self.client.access_control.resource_attributes.list_enum_values( + return self.client.access_control.resource_attributes.enum_values.list_( self, include_archived=include_archived ) @@ -340,13 +340,15 @@ def assign_to( Returns: The created assignments. """ - return self.client.access_control.resource_attributes.assign(self, resources, value=value) + return self.client.access_control.resource_attributes.assignments.create( + self, resources, value=value + ) def list_assignments( self, *, include_archived: bool = False ) -> list[ResourceAttributeAssignment]: """List all assignments of this key.""" - return self.client.access_control.resource_attributes.list_assignments( + return self.client.access_control.resource_attributes.assignments.list_( key=self, include_archived=include_archived ) @@ -356,25 +358,25 @@ def update(self, update: ResourceAttributeKeyUpdate | dict) -> ResourceAttribute Args: update: Either a ResourceAttributeKeyUpdate instance or a dict of fields to update. """ - updated = self.client.access_control.resource_attributes.update_key(self, update=update) + updated = self.client.access_control.resource_attributes.keys.update(self, update=update) self._update(updated) return self def archive(self) -> ResourceAttributeKey: """Archive this key. Cascades to its enum values and assignments.""" - updated = self.client.access_control.resource_attributes.archive_key(self) + updated = self.client.access_control.resource_attributes.keys.archive(self) self._update(updated) return self def unarchive(self) -> ResourceAttributeKey: """Unarchive this key.""" - updated = self.client.access_control.resource_attributes.unarchive_key(self) + updated = self.client.access_control.resource_attributes.keys.unarchive(self) self._update(updated) return self def check_archive_impact(self) -> int: """Return the number of active assignments that archiving this key would affect.""" - return self.client.access_control.resource_attributes.check_key_archive_impact(self) + return self.client.access_control.resource_attributes.keys.check_archive_impact(self) def __str__(self) -> str: return self.display_name From 66762451f78c13d32cd1c321bf3f60a5a5604b04 Mon Sep 17 00:00:00 2001 From: Wei Qi Lu Date: Wed, 8 Jul 2026 11:40:12 -0700 Subject: [PATCH 10/21] raise error on combined arg for assignments.list_ --- .../resources/test_resource_attributes.py | 44 +++++++++++++++++++ .../access_control/resource_attributes.py | 14 +++++- .../resources/sync_stubs/__init__.pyi | 9 +++- 3 files changed, 63 insertions(+), 4 deletions(-) diff --git a/python/lib/sift_client/_tests/resources/test_resource_attributes.py b/python/lib/sift_client/_tests/resources/test_resource_attributes.py index b18d1f2d2..d23f5ca8c 100644 --- a/python/lib/sift_client/_tests/resources/test_resource_attributes.py +++ b/python/lib/sift_client/_tests/resources/test_resource_attributes.py @@ -239,6 +239,50 @@ async def test_rejects_resource_assignment_filters_outside_current_supported_tar api.assignments._low_level_client.list_all_resource_attributes_by_entity.assert_not_called() +class TestAssignmentsListResourceFilter: + @pytest.mark.asyncio + async def test_resource_alone_uses_by_entity_rpc(self): + api = _api() + api.assignments._low_level_client.list_all_resource_attributes_by_entity = AsyncMock( + return_value=[] + ) + + await api.assignments.list_(resource=ResourceAttributeEntity.for_channel("ch1")) + + api.assignments._low_level_client.list_all_resource_attributes_by_entity.assert_awaited_once() + + @pytest.mark.asyncio + async def test_resource_with_key_raises(self): + api = _api() + api.assignments._low_level_client.list_all_resource_attributes_by_entity = AsyncMock() + + with pytest.raises(ValueError, match="cannot be combined"): + await api.assignments.list_( + resource=ResourceAttributeEntity.for_channel("ch1"), key=_key() + ) + + api.assignments._low_level_client.list_all_resource_attributes_by_entity.assert_not_called() + + @pytest.mark.asyncio + async def test_resource_with_filter_query_raises(self): + api = _api() + + with pytest.raises(ValueError, match="cannot be combined"): + await api.assignments.list_( + resource=ResourceAttributeEntity.for_channel("ch1"), + filter_query="is_archived == false", + ) + + @pytest.mark.asyncio + async def test_resource_with_order_by_raises(self): + api = _api() + + with pytest.raises(ValueError, match="cannot be combined"): + await api.assignments.list_( + resource=ResourceAttributeEntity.for_channel("ch1"), order_by="created_date" + ) + + def _asset_proto(): from sift.assets.v1.assets_pb2 import Asset as AssetProto diff --git a/python/lib/sift_client/resources/access_control/resource_attributes.py b/python/lib/sift_client/resources/access_control/resource_attributes.py index 1b12d66c5..4915a85ea 100644 --- a/python/lib/sift_client/resources/access_control/resource_attributes.py +++ b/python/lib/sift_client/resources/access_control/resource_attributes.py @@ -431,15 +431,25 @@ async def list_( Args: key: Filter to assignments of this key. - resource: Filter to assignments on this resource. When set, other filters are ignored. - Pass a resource object, resource ID, or ``ResourceAttributeEntity``. + resource: Filter to assignments on this resource. Cannot be combined with + ``key``, ``filter_query``, or ``order_by``. Pass a resource object, + resource ID, or ``ResourceAttributeEntity``. include_archived: If True, include archived assignments. filter_query: Explicit CEL query. order_by: Field and direction to order by. limit: Maximum number of assignments to return. page_size: Results to fetch per request. + + Raises: + ValueError: If ``resource`` is combined with ``key``, ``filter_query``, or + ``order_by``, which the by-resource listing does not support. """ if resource is not None: + if key is not None or filter_query is not None or order_by is not None: + raise ValueError( + "resource cannot be combined with key, filter_query, or order_by; " + "the by-resource listing does not support additional filters." + ) resolved = await self._resolve_resource(resource) attrs = await self._low_level_client.list_all_resource_attributes_by_entity( entity=resolved, diff --git a/python/lib/sift_client/resources/sync_stubs/__init__.pyi b/python/lib/sift_client/resources/sync_stubs/__init__.pyi index d4dec68db..16e249e00 100644 --- a/python/lib/sift_client/resources/sync_stubs/__init__.pyi +++ b/python/lib/sift_client/resources/sync_stubs/__init__.pyi @@ -1935,13 +1935,18 @@ class ResourceAttributeAssignmentsAPI: Args: key: Filter to assignments of this key. - resource: Filter to assignments on this resource. When set, other filters are ignored. - Pass a resource object, resource ID, or ``ResourceAttributeEntity``. + resource: Filter to assignments on this resource. Cannot be combined with + ``key``, ``filter_query``, or ``order_by``. Pass a resource object, + resource ID, or ``ResourceAttributeEntity``. include_archived: If True, include archived assignments. filter_query: Explicit CEL query. order_by: Field and direction to order by. limit: Maximum number of assignments to return. page_size: Results to fetch per request. + + Raises: + ValueError: If ``resource`` is combined with ``key``, ``filter_query``, or + ``order_by``, which the by-resource listing does not support. """ ... From e6b6f6f4c5bedec106e2bf726852ce49e4776adf Mon Sep 17 00:00:00 2001 From: Wei Qi Lu Date: Wed, 8 Jul 2026 12:49:07 -0700 Subject: [PATCH 11/21] update docstrings to inckude full args/return --- .../access_control/principal_attributes.py | 158 +++++++++- .../access_control/resource_attributes.py | 136 +++++++- .../resources/sync_stubs/__init__.pyi | 294 ++++++++++++++++-- 3 files changed, 528 insertions(+), 60 deletions(-) diff --git a/python/lib/sift_client/resources/access_control/principal_attributes.py b/python/lib/sift_client/resources/access_control/principal_attributes.py index 8136060e6..711be5e0f 100644 --- a/python/lib/sift_client/resources/access_control/principal_attributes.py +++ b/python/lib/sift_client/resources/access_control/principal_attributes.py @@ -79,7 +79,14 @@ def __init__(self, sift_client: SiftClient): ) async def get(self, *, key_id: str) -> PrincipalAttributeKey: - """Get a principal attribute key by ID.""" + """Get a principal attribute key by ID. + + Args: + key_id: The ID of the key. + + Returns: + The key. + """ key = await self._low_level_client.get_key(key_id) return self._apply_client_to_instance(key) @@ -110,6 +117,9 @@ async def list_( order_by: Field and direction to order by. limit: Maximum number of keys to return. page_size: Results to fetch per request. + + Returns: + The matching keys. """ # The key list filter exposes the display name as the CEL field `display_name`. filter_parts = self._build_name_cel_filters( @@ -134,7 +144,17 @@ async def list_( return self._apply_client_to_instances(keys) async def find(self, **kwargs) -> PrincipalAttributeKey | None: - """Find a single key matching the query. Raises if more than one matches.""" + """Find a single key matching the query. Takes the same arguments as `list_`. + + Args: + **kwargs: Keyword arguments to pass to `list_`. + + Returns: + The key found, or None if no key matches. + + Raises: + ValueError: If more than one key matches. + """ keys = await self.list_(**kwargs) if len(keys) > 1: raise ValueError(f"Multiple ({len(keys)}) principal attribute keys found for query") @@ -147,7 +167,16 @@ async def create( *, description: str = "", ) -> PrincipalAttributeKey: - """Create a principal attribute key.""" + """Create a principal attribute key. + + Args: + display_name: The human-readable name of the key. + value_type: The value type of the key. + description: Optional description. + + Returns: + The created key. + """ key = await self._low_level_client.create_key( display_name=display_name, value_type=value_type.value, description=description ) @@ -162,6 +191,14 @@ async def get_or_create( ) -> PrincipalAttributeKey: """Get a key by display name, creating it if it does not exist. + Args: + display_name: The human-readable name of the key. + value_type: The value type used if the key is created. + description: Optional description used if the key is created. + + Returns: + The existing or newly created key. + Note: Display names are not guaranteed unique. If multiple keys share the display name, the first active match is returned. @@ -182,6 +219,9 @@ async def update( Args: key: The key or key ID to update. update: Updates to apply to the key. + + Returns: + The updated key. """ if isinstance(update, dict): update = PrincipalAttributeKeyUpdate.model_validate(update) @@ -190,21 +230,41 @@ async def update( return self._apply_client_to_instance(updated) async def archive(self, key: str | PrincipalAttributeKey) -> PrincipalAttributeKey: - """Archive a key. Cascades to its enum values and assignments.""" + """Archive a key. Cascades to its enum values and assignments. + + Args: + key: The key or key ID to archive. + + Returns: + The archived key. + """ key_id = id_of(key) await self._low_level_client.archive_key(key_id) return await self.get(key_id=key_id) async def unarchive(self, key: str | PrincipalAttributeKey) -> PrincipalAttributeKey: - """Unarchive a key. Does not restore its cascaded enum values or assignments.""" + """Unarchive a key. Does not restore its cascaded enum values or assignments. + + Args: + key: The key or key ID to unarchive. + + Returns: + The unarchived key. + """ key_id = id_of(key) await self._low_level_client.unarchive_key(key_id) return await self.get(key_id=key_id) async def check_archive_impact(self, key: str | PrincipalAttributeKey) -> int: - """Return the number of active assignments archiving this key would affect. + """Check how many assignments archiving a key would affect. Counts both user and user-group assignments. + + Args: + key: The key or key ID to check. + + Returns: + The number of active assignments archiving this key would affect. """ return await self._low_level_client.check_key_archive_impact(id_of(key)) @@ -234,7 +294,16 @@ async def create( *, description: str = "", ) -> PrincipalAttributeEnumValue: - """Create a single enum value for a key.""" + """Create a single enum value for a key. + + Args: + key: The key or key ID the enum value belongs to. + display_name: The human-readable name of the enum value. + description: Optional description. + + Returns: + The created enum value. + """ key_id = id_of(key) value = await self._low_level_client.create_enum_value( key_id=key_id, display_name=display_name, description=description @@ -255,7 +324,23 @@ async def list_( limit: int | None = None, page_size: int | None = None, ) -> list[PrincipalAttributeEnumValue]: - """List the enum values defined for a key.""" + """List the enum values defined for a key. + + Args: + key: The key or key ID to list enum values for. + name: Exact display name of the enum value. + names: Display names to filter by. + name_contains: Substring match on the display name. + name_regex: Regex match on the display name. + include_archived: If True, include archived enum values. + filter_query: Explicit CEL query. + order_by: Field and direction to order by. + limit: Maximum number of enum values to return. + page_size: Results to fetch per request. + + Returns: + The matching enum values. + """ key_id = id_of(key) filter_parts = self._build_name_cel_filters( name=name, names=names, name_contains=name_contains, name_regex=name_regex @@ -277,7 +362,12 @@ async def get_or_create( ) -> list[PrincipalAttributeEnumValue]: """Get enum values for a key by name, creating any that don't exist. - Returns the values in the same order as ``names``. + Args: + key: The key or key ID the enum values belong to. + names: Display names of the enum values to get or create. + + Returns: + The enum values, in the same order as ``names``. """ key_id = id_of(key) existing = await self.list_(key_id, include_archived=False) @@ -299,7 +389,13 @@ async def archive( ) -> int: """Archive an enum value, migrating existing assignments to a replacement. - Returns the number of assignments migrated. + Args: + enum_value: The enum value or enum value ID to archive. + replacement: Optional enum value or enum value ID that existing + assignments are migrated to. + + Returns: + The number of assignments migrated. """ enum_value_id = id_of(enum_value) replacement_id = id_of(replacement) if replacement is not None else "" @@ -310,7 +406,14 @@ async def archive( async def unarchive( self, enum_value: str | PrincipalAttributeEnumValue ) -> PrincipalAttributeEnumValue: - """Unarchive an enum value.""" + """Unarchive an enum value. + + Args: + enum_value: The enum value or enum value ID to unarchive. + + Returns: + The unarchived enum value. + """ enum_value_id = id_of(enum_value) await self._low_level_client.unarchive_enum_value(enum_value_id) value = await self._low_level_client.get_enum_value(enum_value_id) @@ -378,9 +481,17 @@ async def get( self, *, assignment_id: str, - principal_type: PrincipalType = PrincipalType.USER, + principal_type: PrincipalType, ) -> PrincipalAttributeAssignment: - """Get a single assignment by ID.""" + """Get a single assignment by ID and principal type. + + Args: + assignment_id: The ID of the assignment. + principal_type: The kind of principal the assignment applies to. + + Returns: + The assignment. + """ value = await self._low_level_client.get_value( assignment_id, principal_type=principal_type.value ) @@ -410,6 +521,9 @@ async def list_( order_by: Field and direction to order by. limit: Maximum number of assignments to return. page_size: Results to fetch per request. + + Returns: + The matching assignments. """ filter_parts = [] if principal is not None: @@ -446,9 +560,14 @@ async def archive( self, assignments: list[str | PrincipalAttributeAssignment], *, - principal_type: PrincipalType = PrincipalType.USER, + principal_type: PrincipalType, ) -> None: - """Batch archive assignments.""" + """Batch archive assignments of the given principal type. + + Args: + assignments: The assignments or assignment IDs to archive. + principal_type: The kind of principal the assignments apply to. + """ ids = [id_of(a) for a in assignments] await self._low_level_client.archive_values(ids, principal_type=principal_type.value) @@ -456,9 +575,14 @@ async def unarchive( self, assignments: list[str | PrincipalAttributeAssignment], *, - principal_type: PrincipalType = PrincipalType.USER, + principal_type: PrincipalType, ) -> None: - """Batch unarchive assignments.""" + """Batch unarchive assignments of the given principal type. + + Args: + assignments: The assignments or assignment IDs to unarchive. + principal_type: The kind of principal the assignments apply to. + """ ids = [id_of(a) for a in assignments] await self._low_level_client.unarchive_values(ids, principal_type=principal_type.value) diff --git a/python/lib/sift_client/resources/access_control/resource_attributes.py b/python/lib/sift_client/resources/access_control/resource_attributes.py index 4915a85ea..d8cf14c35 100644 --- a/python/lib/sift_client/resources/access_control/resource_attributes.py +++ b/python/lib/sift_client/resources/access_control/resource_attributes.py @@ -115,7 +115,14 @@ def __init__(self, sift_client: SiftClient): ) async def get(self, *, key_id: str) -> ResourceAttributeKey: - """Get a resource attribute key by ID.""" + """Get a resource attribute key by ID. + + Args: + key_id: The ID of the key. + + Returns: + The key. + """ key = await self._low_level_client.get_key(key_id) return self._apply_client_to_instance(key) @@ -170,7 +177,17 @@ async def list_( return self._apply_client_to_instances(keys) async def find(self, **kwargs) -> ResourceAttributeKey | None: - """Find a single key matching the query. Raises if more than one matches.""" + """Find a single key matching the query. Takes the same arguments as `list_`. + + Args: + **kwargs: Keyword arguments to pass to `list_`. + + Returns: + The key found, or None if no key matches. + + Raises: + ValueError: If more than one key matches. + """ keys = await self.list_(**kwargs) if len(keys) > 1: raise ValueError(f"Multiple ({len(keys)}) resource attribute keys found for query") @@ -207,6 +224,14 @@ async def get_or_create( ) -> ResourceAttributeKey: """Get a key by display name, creating it if it does not exist. + Args: + display_name: The human-readable name of the key. + value_type: The value type used if the key is created. + description: Optional description used if the key is created. + + Returns: + The existing or newly created key. + Note: Display names are not guaranteed unique. If multiple keys share the display name, the first active match is returned. @@ -227,6 +252,9 @@ async def update( Args: key: The key or key ID to update. update: Updates to apply to the key. + + Returns: + The updated key. """ if isinstance(update, dict): update = ResourceAttributeKeyUpdate.model_validate(update) @@ -235,19 +263,40 @@ async def update( return self._apply_client_to_instance(updated) async def archive(self, key: str | ResourceAttributeKey) -> ResourceAttributeKey: - """Archive a key. Cascades to its enum values and assignments.""" + """Archive a key. Cascades to its enum values and assignments. + + Args: + key: The key or key ID to archive. + + Returns: + The archived key. + """ key_id = id_of(key) await self._low_level_client.archive_key(key_id) return await self.get(key_id=key_id) async def unarchive(self, key: str | ResourceAttributeKey) -> ResourceAttributeKey: - """Unarchive a key. Does not restore its cascaded enum values or assignments.""" + """Unarchive a key. Does not restore its cascaded enum values or assignments. + + Args: + key: The key or key ID to unarchive. + + Returns: + The unarchived key. + """ key_id = id_of(key) await self._low_level_client.unarchive_key(key_id) return await self.get(key_id=key_id) async def check_archive_impact(self, key: str | ResourceAttributeKey) -> int: - """Return the number of active assignments archiving this key would affect.""" + """Check how many assignments archiving a key would affect. + + Args: + key: The key or key ID to check. + + Returns: + The number of active assignments archiving this key would affect. + """ return await self._low_level_client.check_key_archive_impact(id_of(key)) @@ -276,7 +325,16 @@ async def create( *, description: str = "", ) -> ResourceAttributeEnumValue: - """Create a single enum value for a key.""" + """Create a single enum value for a key. + + Args: + key: The key or key ID the enum value belongs to. + display_name: The human-readable name of the enum value. + description: Optional description. + + Returns: + The created enum value. + """ key_id = id_of(key) value = await self._low_level_client.create_enum_value( key_id=key_id, display_name=display_name, description=description @@ -297,7 +355,23 @@ async def list_( limit: int | None = None, page_size: int | None = None, ) -> list[ResourceAttributeEnumValue]: - """List the enum values defined for a key.""" + """List the enum values defined for a key. + + Args: + key: The key or key ID to list enum values for. + name: Exact display name of the enum value. + names: Display names to filter by. + name_contains: Substring match on the display name. + name_regex: Regex match on the display name. + include_archived: If True, include archived enum values. + filter_query: Explicit CEL query. + order_by: Field and direction to order by. + limit: Maximum number of enum values to return. + page_size: Results to fetch per request. + + Returns: + The matching enum values. + """ key_id = id_of(key) filter_parts = self._build_name_cel_filters( name=name, names=names, name_contains=name_contains, name_regex=name_regex @@ -319,7 +393,12 @@ async def get_or_create( ) -> list[ResourceAttributeEnumValue]: """Get enum values for a key by name, creating any that don't exist. - Returns the values in the same order as ``names``. + Args: + key: The key or key ID the enum values belong to. + names: Display names of the enum values to get or create. + + Returns: + The enum values, in the same order as ``names``. """ key_id = id_of(key) existing = await self.list_(key_id, include_archived=False) @@ -341,7 +420,13 @@ async def archive( ) -> int: """Archive an enum value, migrating existing assignments to a replacement. - Returns the number of assignments migrated. + Args: + enum_value: The enum value or enum value ID to archive. + replacement: Optional enum value or enum value ID that existing + assignments are migrated to. + + Returns: + The number of assignments migrated. """ enum_value_id = id_of(enum_value) replacement_id = id_of(replacement) if replacement is not None else "" @@ -352,7 +437,14 @@ async def archive( async def unarchive( self, enum_value: str | ResourceAttributeEnumValue ) -> ResourceAttributeEnumValue: - """Unarchive an enum value.""" + """Unarchive an enum value. + + Args: + enum_value: The enum value or enum value ID to unarchive. + + Returns: + The unarchived enum value. + """ enum_value_id = id_of(enum_value) await self._low_level_client.unarchive_enum_value(enum_value_id) value = await self._low_level_client.get_enum_value(enum_value_id) @@ -412,7 +504,14 @@ async def create( return self._apply_client_to_instances(created) async def get(self, *, assignment_id: str) -> ResourceAttributeAssignment: - """Get a single assignment by ID.""" + """Get a single assignment by ID. + + Args: + assignment_id: The ID of the assignment. + + Returns: + The assignment. + """ attr = await self._low_level_client.get_resource_attribute(assignment_id) return self._apply_client_to_instance(attr) @@ -440,6 +539,9 @@ async def list_( limit: Maximum number of assignments to return. page_size: Results to fetch per request. + Returns: + The matching assignments. + Raises: ValueError: If ``resource`` is combined with ``key``, ``filter_query``, or ``order_by``, which the by-resource listing does not support. @@ -475,12 +577,20 @@ async def list_( return self._apply_client_to_instances(attrs) async def archive(self, assignments: list[str | ResourceAttributeAssignment]) -> None: - """Batch archive assignments.""" + """Batch archive assignments. + + Args: + assignments: The assignments or assignment IDs to archive. + """ ids = [id_of(a) for a in assignments] await self._low_level_client.batch_archive_resource_attributes(ids) async def unarchive(self, assignments: list[str | ResourceAttributeAssignment]) -> None: - """Batch unarchive assignments.""" + """Batch unarchive assignments. + + Args: + assignments: The assignments or assignment IDs to unarchive. + """ ids = [id_of(a) for a in assignments] await self._low_level_client.batch_unarchive_resource_attributes(ids) diff --git a/python/lib/sift_client/resources/sync_stubs/__init__.pyi b/python/lib/sift_client/resources/sync_stubs/__init__.pyi index 16e249e00..5d529a537 100644 --- a/python/lib/sift_client/resources/sync_stubs/__init__.pyi +++ b/python/lib/sift_client/resources/sync_stubs/__init__.pyi @@ -1207,9 +1207,14 @@ class PrincipalAttributeAssignmentsAPI: self, assignments: list[str | PrincipalAttributeAssignment], *, - principal_type: PrincipalType = PrincipalType.USER, + principal_type: PrincipalType, ) -> None: - """Batch archive assignments.""" + """Batch archive assignments of the given principal type. + + Args: + assignments: The assignments or assignment IDs to archive. + principal_type: The kind of principal the assignments apply to. + """ ... def create( @@ -1238,9 +1243,17 @@ class PrincipalAttributeAssignmentsAPI: ... def get( - self, *, assignment_id: str, principal_type: PrincipalType = PrincipalType.USER + self, *, assignment_id: str, principal_type: PrincipalType ) -> PrincipalAttributeAssignment: - """Get a single assignment by ID.""" + """Get a single assignment by ID and principal type. + + Args: + assignment_id: The ID of the assignment. + principal_type: The kind of principal the assignment applies to. + + Returns: + The assignment. + """ ... def list_( @@ -1267,6 +1280,9 @@ class PrincipalAttributeAssignmentsAPI: order_by: Field and direction to order by. limit: Maximum number of assignments to return. page_size: Results to fetch per request. + + Returns: + The matching assignments. """ ... @@ -1274,9 +1290,14 @@ class PrincipalAttributeAssignmentsAPI: self, assignments: list[str | PrincipalAttributeAssignment], *, - principal_type: PrincipalType = PrincipalType.USER, + principal_type: PrincipalType, ) -> None: - """Batch unarchive assignments.""" + """Batch unarchive assignments of the given principal type. + + Args: + assignments: The assignments or assignment IDs to unarchive. + principal_type: The kind of principal the assignments apply to. + """ ... class PrincipalAttributeEnumValuesAPI: @@ -1305,14 +1326,29 @@ class PrincipalAttributeEnumValuesAPI: ) -> int: """Archive an enum value, migrating existing assignments to a replacement. - Returns the number of assignments migrated. + Args: + enum_value: The enum value or enum value ID to archive. + replacement: Optional enum value or enum value ID that existing + assignments are migrated to. + + Returns: + The number of assignments migrated. """ ... def create( self, key: str | PrincipalAttributeKey, display_name: str, *, description: str = "" ) -> PrincipalAttributeEnumValue: - """Create a single enum value for a key.""" + """Create a single enum value for a key. + + Args: + key: The key or key ID the enum value belongs to. + display_name: The human-readable name of the enum value. + description: Optional description. + + Returns: + The created enum value. + """ ... def get_or_create( @@ -1320,7 +1356,12 @@ class PrincipalAttributeEnumValuesAPI: ) -> list[PrincipalAttributeEnumValue]: """Get enum values for a key by name, creating any that don't exist. - Returns the values in the same order as ``names``. + Args: + key: The key or key ID the enum values belong to. + names: Display names of the enum values to get or create. + + Returns: + The enum values, in the same order as ``names``. """ ... @@ -1338,13 +1379,36 @@ class PrincipalAttributeEnumValuesAPI: limit: int | None = None, page_size: int | None = None, ) -> list[PrincipalAttributeEnumValue]: - """List the enum values defined for a key.""" + """List the enum values defined for a key. + + Args: + key: The key or key ID to list enum values for. + name: Exact display name of the enum value. + names: Display names to filter by. + name_contains: Substring match on the display name. + name_regex: Regex match on the display name. + include_archived: If True, include archived enum values. + filter_query: Explicit CEL query. + order_by: Field and direction to order by. + limit: Maximum number of enum values to return. + page_size: Results to fetch per request. + + Returns: + The matching enum values. + """ ... def unarchive( self, enum_value: str | PrincipalAttributeEnumValue ) -> PrincipalAttributeEnumValue: - """Unarchive an enum value.""" + """Unarchive an enum value. + + Args: + enum_value: The enum value or enum value ID to unarchive. + + Returns: + The unarchived enum value. + """ ... class PrincipalAttributeKeysAPI: @@ -1365,28 +1429,67 @@ class PrincipalAttributeKeysAPI: def _run(self, coro): ... def archive(self, key: str | PrincipalAttributeKey) -> PrincipalAttributeKey: - """Archive a key. Cascades to its enum values and assignments.""" + """Archive a key. Cascades to its enum values and assignments. + + Args: + key: The key or key ID to archive. + + Returns: + The archived key. + """ ... def check_archive_impact(self, key: str | PrincipalAttributeKey) -> int: - """Return the number of active assignments archiving this key would affect. + """Check how many assignments archiving a key would affect. Counts both user and user-group assignments. + + Args: + key: The key or key ID to check. + + Returns: + The number of active assignments archiving this key would affect. """ ... def create( self, display_name: str, value_type: PrincipalAttributeValueType, *, description: str = "" ) -> PrincipalAttributeKey: - """Create a principal attribute key.""" + """Create a principal attribute key. + + Args: + display_name: The human-readable name of the key. + value_type: The value type of the key. + description: Optional description. + + Returns: + The created key. + """ ... def find(self, **kwargs) -> PrincipalAttributeKey | None: - """Find a single key matching the query. Raises if more than one matches.""" + """Find a single key matching the query. Takes the same arguments as `list_`. + + Args: + **kwargs: Keyword arguments to pass to `list_`. + + Returns: + The key found, or None if no key matches. + + Raises: + ValueError: If more than one key matches. + """ ... def get(self, *, key_id: str) -> PrincipalAttributeKey: - """Get a principal attribute key by ID.""" + """Get a principal attribute key by ID. + + Args: + key_id: The ID of the key. + + Returns: + The key. + """ ... def get_or_create( @@ -1394,6 +1497,14 @@ class PrincipalAttributeKeysAPI: ) -> PrincipalAttributeKey: """Get a key by display name, creating it if it does not exist. + Args: + display_name: The human-readable name of the key. + value_type: The value type used if the key is created. + description: Optional description used if the key is created. + + Returns: + The existing or newly created key. + Note: Display names are not guaranteed unique. If multiple keys share the display name, the first active match is returned. @@ -1427,11 +1538,21 @@ class PrincipalAttributeKeysAPI: order_by: Field and direction to order by. limit: Maximum number of keys to return. page_size: Results to fetch per request. + + Returns: + The matching keys. """ ... def unarchive(self, key: str | PrincipalAttributeKey) -> PrincipalAttributeKey: - """Unarchive a key. Does not restore its cascaded enum values or assignments.""" + """Unarchive a key. Does not restore its cascaded enum values or assignments. + + Args: + key: The key or key ID to unarchive. + + Returns: + The unarchived key. + """ ... def update( @@ -1442,6 +1563,9 @@ class PrincipalAttributeKeysAPI: Args: key: The key or key ID to update. update: Updates to apply to the key. + + Returns: + The updated key. """ ... @@ -1890,7 +2014,11 @@ class ResourceAttributeAssignmentsAPI: def _run(self, coro): ... def archive(self, assignments: list[str | ResourceAttributeAssignment]) -> None: - """Batch archive assignments.""" + """Batch archive assignments. + + Args: + assignments: The assignments or assignment IDs to archive. + """ ... def create( @@ -1917,7 +2045,14 @@ class ResourceAttributeAssignmentsAPI: ... def get(self, *, assignment_id: str) -> ResourceAttributeAssignment: - """Get a single assignment by ID.""" + """Get a single assignment by ID. + + Args: + assignment_id: The ID of the assignment. + + Returns: + The assignment. + """ ... def list_( @@ -1944,6 +2079,9 @@ class ResourceAttributeAssignmentsAPI: limit: Maximum number of assignments to return. page_size: Results to fetch per request. + Returns: + The matching assignments. + Raises: ValueError: If ``resource`` is combined with ``key``, ``filter_query``, or ``order_by``, which the by-resource listing does not support. @@ -1951,7 +2089,11 @@ class ResourceAttributeAssignmentsAPI: ... def unarchive(self, assignments: list[str | ResourceAttributeAssignment]) -> None: - """Batch unarchive assignments.""" + """Batch unarchive assignments. + + Args: + assignments: The assignments or assignment IDs to unarchive. + """ ... class ResourceAttributeEnumValuesAPI: @@ -1980,14 +2122,29 @@ class ResourceAttributeEnumValuesAPI: ) -> int: """Archive an enum value, migrating existing assignments to a replacement. - Returns the number of assignments migrated. + Args: + enum_value: The enum value or enum value ID to archive. + replacement: Optional enum value or enum value ID that existing + assignments are migrated to. + + Returns: + The number of assignments migrated. """ ... def create( self, key: str | ResourceAttributeKey, display_name: str, *, description: str = "" ) -> ResourceAttributeEnumValue: - """Create a single enum value for a key.""" + """Create a single enum value for a key. + + Args: + key: The key or key ID the enum value belongs to. + display_name: The human-readable name of the enum value. + description: Optional description. + + Returns: + The created enum value. + """ ... def get_or_create( @@ -1995,7 +2152,12 @@ class ResourceAttributeEnumValuesAPI: ) -> list[ResourceAttributeEnumValue]: """Get enum values for a key by name, creating any that don't exist. - Returns the values in the same order as ``names``. + Args: + key: The key or key ID the enum values belong to. + names: Display names of the enum values to get or create. + + Returns: + The enum values, in the same order as ``names``. """ ... @@ -2013,11 +2175,34 @@ class ResourceAttributeEnumValuesAPI: limit: int | None = None, page_size: int | None = None, ) -> list[ResourceAttributeEnumValue]: - """List the enum values defined for a key.""" + """List the enum values defined for a key. + + Args: + key: The key or key ID to list enum values for. + name: Exact display name of the enum value. + names: Display names to filter by. + name_contains: Substring match on the display name. + name_regex: Regex match on the display name. + include_archived: If True, include archived enum values. + filter_query: Explicit CEL query. + order_by: Field and direction to order by. + limit: Maximum number of enum values to return. + page_size: Results to fetch per request. + + Returns: + The matching enum values. + """ ... def unarchive(self, enum_value: str | ResourceAttributeEnumValue) -> ResourceAttributeEnumValue: - """Unarchive an enum value.""" + """Unarchive an enum value. + + Args: + enum_value: The enum value or enum value ID to unarchive. + + Returns: + The unarchived enum value. + """ ... class ResourceAttributeKeysAPI: @@ -2038,11 +2223,25 @@ class ResourceAttributeKeysAPI: def _run(self, coro): ... def archive(self, key: str | ResourceAttributeKey) -> ResourceAttributeKey: - """Archive a key. Cascades to its enum values and assignments.""" + """Archive a key. Cascades to its enum values and assignments. + + Args: + key: The key or key ID to archive. + + Returns: + The archived key. + """ ... def check_archive_impact(self, key: str | ResourceAttributeKey) -> int: - """Return the number of active assignments archiving this key would affect.""" + """Check how many assignments archiving a key would affect. + + Args: + key: The key or key ID to check. + + Returns: + The number of active assignments archiving this key would affect. + """ ... def create( @@ -2061,11 +2260,28 @@ class ResourceAttributeKeysAPI: ... def find(self, **kwargs) -> ResourceAttributeKey | None: - """Find a single key matching the query. Raises if more than one matches.""" + """Find a single key matching the query. Takes the same arguments as `list_`. + + Args: + **kwargs: Keyword arguments to pass to `list_`. + + Returns: + The key found, or None if no key matches. + + Raises: + ValueError: If more than one key matches. + """ ... def get(self, *, key_id: str) -> ResourceAttributeKey: - """Get a resource attribute key by ID.""" + """Get a resource attribute key by ID. + + Args: + key_id: The ID of the key. + + Returns: + The key. + """ ... def get_or_create( @@ -2073,6 +2289,14 @@ class ResourceAttributeKeysAPI: ) -> ResourceAttributeKey: """Get a key by display name, creating it if it does not exist. + Args: + display_name: The human-readable name of the key. + value_type: The value type used if the key is created. + description: Optional description used if the key is created. + + Returns: + The existing or newly created key. + Note: Display names are not guaranteed unique. If multiple keys share the display name, the first active match is returned. @@ -2113,7 +2337,14 @@ class ResourceAttributeKeysAPI: ... def unarchive(self, key: str | ResourceAttributeKey) -> ResourceAttributeKey: - """Unarchive a key. Does not restore its cascaded enum values or assignments.""" + """Unarchive a key. Does not restore its cascaded enum values or assignments. + + Args: + key: The key or key ID to unarchive. + + Returns: + The unarchived key. + """ ... def update( @@ -2124,6 +2355,9 @@ class ResourceAttributeKeysAPI: Args: key: The key or key ID to update. update: Updates to apply to the key. + + Returns: + The updated key. """ ... From 73e9b08653dac104635d67ab0a4f42645fa2d565 Mon Sep 17 00:00:00 2001 From: Wei Qi Lu Date: Wed, 8 Jul 2026 16:03:57 -0700 Subject: [PATCH 12/21] drop UNSPECIFIED from public enums --- .../test_principal_attributes.py | 10 ++++-- .../test_resource_attributes.py | 19 ++++++++--- .../resources/test_resource_attributes.py | 32 ------------------- .../sift_types/test_principal_attribute.py | 6 +++- .../sift_types/test_resource_attribute.py | 6 +++- .../access_control/resource_attributes.py | 7 ---- .../sift_types/principal_attribute.py | 2 -- .../sift_types/resource_attribute.py | 2 -- 8 files changed, 33 insertions(+), 51 deletions(-) diff --git a/python/lib/sift_client/_tests/_internal/low_level_wrappers/test_principal_attributes.py b/python/lib/sift_client/_tests/_internal/low_level_wrappers/test_principal_attributes.py index 41a55aae3..90b17fd63 100644 --- a/python/lib/sift_client/_tests/_internal/low_level_wrappers/test_principal_attributes.py +++ b/python/lib/sift_client/_tests/_internal/low_level_wrappers/test_principal_attributes.py @@ -54,7 +54,10 @@ async def test_sends_principal_type_and_enum_id_list(self): stub.BatchCreatePrincipalAttributeValue = AsyncMock( return_value=pa.BatchCreatePrincipalAttributeValueResponse( principal_attribute_values=[ - pa.PrincipalAttributeValue(principal_attribute_value_id="v1") + pa.PrincipalAttributeValue( + principal_attribute_value_id="v1", + principal_type=pa.PRINCIPAL_ATTRIBUTE_PRINCIPAL_TYPE_USER, + ) ] ) ) @@ -117,7 +120,10 @@ async def test_splits_principal_ids_over_batch_size_into_multiple_rpcs(self): stub.BatchCreatePrincipalAttributeValue = AsyncMock( return_value=pa.BatchCreatePrincipalAttributeValueResponse( principal_attribute_values=[ - pa.PrincipalAttributeValue(principal_attribute_value_id="v1") + pa.PrincipalAttributeValue( + principal_attribute_value_id="v1", + principal_type=pa.PRINCIPAL_ATTRIBUTE_PRINCIPAL_TYPE_USER, + ) ] ) ) diff --git a/python/lib/sift_client/_tests/_internal/low_level_wrappers/test_resource_attributes.py b/python/lib/sift_client/_tests/_internal/low_level_wrappers/test_resource_attributes.py index 38cf6258f..927a89683 100644 --- a/python/lib/sift_client/_tests/_internal/low_level_wrappers/test_resource_attributes.py +++ b/python/lib/sift_client/_tests/_internal/low_level_wrappers/test_resource_attributes.py @@ -27,7 +27,9 @@ async def test_sends_type_and_returns_key(self): stub.CreateResourceAttributeKey = AsyncMock( return_value=ra.CreateResourceAttributeKeyResponse( resource_attribute_key=ra.ResourceAttributeKey( - resource_attribute_key_id="k1", display_name="licenses" + resource_attribute_key_id="k1", + display_name="licenses", + type=ra.RESOURCE_ATTRIBUTE_KEY_TYPE_SET_OF_ENUM, ) ) ) @@ -49,7 +51,10 @@ async def test_only_sets_provided_fields_in_mask(self): stub = MagicMock() stub.UpdateResourceAttributeKey = AsyncMock( return_value=ra.UpdateResourceAttributeKeyResponse( - resource_attribute_key=ra.ResourceAttributeKey(resource_attribute_key_id="k1") + resource_attribute_key=ra.ResourceAttributeKey( + resource_attribute_key_id="k1", + type=ra.RESOURCE_ATTRIBUTE_KEY_TYPE_SET_OF_ENUM, + ) ) ) client = _client_with_stub(stub) @@ -72,13 +77,19 @@ async def test_follows_pagination(self): side_effect=[ ra.ListResourceAttributeKeysResponse( resource_attribute_keys=[ - ra.ResourceAttributeKey(resource_attribute_key_id="k1") + ra.ResourceAttributeKey( + resource_attribute_key_id="k1", + type=ra.RESOURCE_ATTRIBUTE_KEY_TYPE_SET_OF_ENUM, + ) ], next_page_token="tok", ), ra.ListResourceAttributeKeysResponse( resource_attribute_keys=[ - ra.ResourceAttributeKey(resource_attribute_key_id="k2") + ra.ResourceAttributeKey( + resource_attribute_key_id="k2", + type=ra.RESOURCE_ATTRIBUTE_KEY_TYPE_SET_OF_ENUM, + ) ], next_page_token="", ), diff --git a/python/lib/sift_client/_tests/resources/test_resource_attributes.py b/python/lib/sift_client/_tests/resources/test_resource_attributes.py index d23f5ca8c..e0d3af0de 100644 --- a/python/lib/sift_client/_tests/resources/test_resource_attributes.py +++ b/python/lib/sift_client/_tests/resources/test_resource_attributes.py @@ -206,38 +206,6 @@ async def test_rejects_unknown_resource_ids(self): api.assignments._low_level_client.batch_create_resource_attributes.assert_not_called() - @pytest.mark.asyncio - async def test_rejects_resource_entity_types_outside_current_supported_targets(self): - api = _api() - api.assignments._low_level_client.batch_create_resource_attributes = AsyncMock( - return_value=[] - ) - unsupported = ResourceAttributeEntity( - entity_id="unknown", - entity_type=ResourceAttributeEntityType.UNSPECIFIED, - ) - - with pytest.raises(ValueError, match="currently support assets, channels, and runs"): - await api.assignments.create(_key(), [unsupported], value=["e_a"]) - - api.assignments._low_level_client.batch_create_resource_attributes.assert_not_called() - - @pytest.mark.asyncio - async def test_rejects_resource_assignment_filters_outside_current_supported_targets(self): - api = _api() - api.assignments._low_level_client.list_all_resource_attributes_by_entity = AsyncMock( - return_value=[] - ) - unsupported = ResourceAttributeEntity( - entity_id="unknown", - entity_type=ResourceAttributeEntityType.UNSPECIFIED, - ) - - with pytest.raises(ValueError, match="currently support assets, channels, and runs"): - await api.assignments.list_(resource=unsupported) - - api.assignments._low_level_client.list_all_resource_attributes_by_entity.assert_not_called() - class TestAssignmentsListResourceFilter: @pytest.mark.asyncio diff --git a/python/lib/sift_client/_tests/sift_types/test_principal_attribute.py b/python/lib/sift_client/_tests/sift_types/test_principal_attribute.py index 56aaa8090..f0340e490 100644 --- a/python/lib/sift_client/_tests/sift_types/test_principal_attribute.py +++ b/python/lib/sift_client/_tests/sift_types/test_principal_attribute.py @@ -87,7 +87,11 @@ def test_apply_client_cascades_to_nested_key_and_enum_value(self, mock_client): principal_id="u1", principal_type=pa.PRINCIPAL_ATTRIBUTE_PRINCIPAL_TYPE_USER, principal_attribute_enum_value_id="ev1", - key=pa.PrincipalAttributeKey(principal_attribute_key_id="pk1", display_name="licenses"), + key=pa.PrincipalAttributeKey( + principal_attribute_key_id="pk1", + display_name="licenses", + type=pa.PRINCIPAL_ATTRIBUTE_VALUE_TYPE_SET_OF_ENUM, + ), enum_value_details=pa.PrincipalAttributeEnumValue( principal_attribute_enum_value_id="ev1", display_name="LIC_A" ), diff --git a/python/lib/sift_client/_tests/sift_types/test_resource_attribute.py b/python/lib/sift_client/_tests/sift_types/test_resource_attribute.py index 83cf61815..28a6760bc 100644 --- a/python/lib/sift_client/_tests/sift_types/test_resource_attribute.py +++ b/python/lib/sift_client/_tests/sift_types/test_resource_attribute.py @@ -98,7 +98,11 @@ def test_apply_client_cascades_to_nested_key_and_enum_value(self, mock_client): resource_attribute_id="a1", resource_attribute_key_id="k1", resource_attribute_enum_value_id="ev1", - key=ra.ResourceAttributeKey(resource_attribute_key_id="k1", display_name="licenses"), + key=ra.ResourceAttributeKey( + resource_attribute_key_id="k1", + display_name="licenses", + type=ra.RESOURCE_ATTRIBUTE_KEY_TYPE_SET_OF_ENUM, + ), enum_value_details=ra.ResourceAttributeEnumValue( resource_attribute_enum_value_id="ev1", display_name="LIC_A" ), diff --git a/python/lib/sift_client/resources/access_control/resource_attributes.py b/python/lib/sift_client/resources/access_control/resource_attributes.py index d8cf14c35..717ad0af6 100644 --- a/python/lib/sift_client/resources/access_control/resource_attributes.py +++ b/python/lib/sift_client/resources/access_control/resource_attributes.py @@ -38,18 +38,11 @@ _RESOLVE_BATCH_SIZE = 1000 ResourceLike = Union[ResourceAttributeEntity, Asset, Channel, Run, str] -SUPPORTED_RESOURCE_ENTITY_TYPES = { - ResourceAttributeEntityType.ASSET, - ResourceAttributeEntityType.CHANNEL, - ResourceAttributeEntityType.RUN, -} def _resolve_resource_object(resource: ResourceLike) -> ResourceAttributeEntity: """Resolve a supported resource object to a ResourceAttributeEntity.""" if isinstance(resource, ResourceAttributeEntity): - if resource.entity_type not in SUPPORTED_RESOURCE_ENTITY_TYPES: - raise ValueError("Resource attributes currently support assets, channels, and runs.") return resource if isinstance(resource, Asset): return ResourceAttributeEntity.for_asset(resource._id_or_error) diff --git a/python/lib/sift_client/sift_types/principal_attribute.py b/python/lib/sift_client/sift_types/principal_attribute.py index 0bccd7e2d..718ed6364 100644 --- a/python/lib/sift_client/sift_types/principal_attribute.py +++ b/python/lib/sift_client/sift_types/principal_attribute.py @@ -29,7 +29,6 @@ class PrincipalAttributeValueType(Enum): """Value type of a principal attribute key.""" - UNSPECIFIED = pa_pb.PRINCIPAL_ATTRIBUTE_VALUE_TYPE_UNSPECIFIED ENUM = pa_pb.PRINCIPAL_ATTRIBUTE_VALUE_TYPE_ENUM BOOLEAN = pa_pb.PRINCIPAL_ATTRIBUTE_VALUE_TYPE_BOOLEAN NUMBER = pa_pb.PRINCIPAL_ATTRIBUTE_VALUE_TYPE_NUMBER @@ -39,7 +38,6 @@ class PrincipalAttributeValueType(Enum): class PrincipalType(Enum): """Kind of principal a principal attribute can be assigned to.""" - UNSPECIFIED = pa_pb.PRINCIPAL_ATTRIBUTE_PRINCIPAL_TYPE_UNSPECIFIED USER = pa_pb.PRINCIPAL_ATTRIBUTE_PRINCIPAL_TYPE_USER USER_GROUP = pa_pb.PRINCIPAL_ATTRIBUTE_PRINCIPAL_TYPE_USER_GROUP diff --git a/python/lib/sift_client/sift_types/resource_attribute.py b/python/lib/sift_client/sift_types/resource_attribute.py index d1468d9dd..3d793159e 100644 --- a/python/lib/sift_client/sift_types/resource_attribute.py +++ b/python/lib/sift_client/sift_types/resource_attribute.py @@ -32,7 +32,6 @@ class ResourceAttributeValueType(Enum): """Value type of a resource attribute key.""" - UNSPECIFIED = ra_pb.RESOURCE_ATTRIBUTE_KEY_TYPE_UNSPECIFIED ENUM = ra_pb.RESOURCE_ATTRIBUTE_KEY_TYPE_ENUM BOOLEAN = ra_pb.RESOURCE_ATTRIBUTE_KEY_TYPE_BOOLEAN NUMBER = ra_pb.RESOURCE_ATTRIBUTE_KEY_TYPE_NUMBER @@ -42,7 +41,6 @@ class ResourceAttributeValueType(Enum): class ResourceAttributeEntityType(Enum): """Kind of Sift resource a resource attribute can be assigned to.""" - UNSPECIFIED = ra_pb.RESOURCE_ATTRIBUTE_ENTITY_TYPE_UNSPECIFIED ASSET = ra_pb.RESOURCE_ATTRIBUTE_ENTITY_TYPE_ASSET CHANNEL = ra_pb.RESOURCE_ATTRIBUTE_ENTITY_TYPE_CHANNEL RUN = ra_pb.RESOURCE_ATTRIBUTE_ENTITY_TYPE_RUN From 30acf853042888f67a04878a9f3b93f7e657ffec Mon Sep 17 00:00:00 2001 From: Wei Qi Lu Date: Fri, 10 Jul 2026 14:57:59 -0700 Subject: [PATCH 13/21] drop bare resource IDs, require typed ref --- python/CHANGELOG.md | 6 +- .../resources/test_resource_attributes.py | 45 +------ .../access_control/resource_attributes.py | 122 +++--------------- .../resources/sync_stubs/__init__.pyi | 20 +-- .../sift_types/resource_attribute.py | 8 +- 5 files changed, 36 insertions(+), 165 deletions(-) diff --git a/python/CHANGELOG.md b/python/CHANGELOG.md index 3095793d9..1bae9efb9 100644 --- a/python/CHANGELOG.md +++ b/python/CHANGELOG.md @@ -65,17 +65,17 @@ Breaking change: `client.reports.create_from_template` now takes `report_templat Added a public API for attribute-based access control (ABAC) attributes under `client.access_control`. Resource attributes describe the Sift objects an access decision applies to, such as assets, channels, and runs. Principal attributes describe the users or groups an access decision applies to. Async APIs are available under `client.async_.access_control`. -An attribute key is the entry point. Create or fetch a key, define its enum values, then assign a value to a set of resources. Resource assignments accept supported resource objects or IDs: +An attribute key is the entry point. Create or fetch a key, define its enum values, then assign a value to a set of resources. Resource assignments accept supported resource objects, or a typed `ResourceAttributeEntity` when you only have an ID: ```python -from sift_client.sift_types import ResourceAttributeValueType +from sift_client.sift_types import ResourceAttributeEntity, ResourceAttributeValueType key = client.access_control.resource_attributes.keys.get_or_create( "licenses", ResourceAttributeValueType.SET_OF_ENUM, ) licenses = key.get_or_create_enum_values(["LICENSE_A", "LICENSE_B"]) -key.assign_to(["channel-id"], value=licenses) +key.assign_to([ResourceAttributeEntity.for_channel("channel-id")], value=licenses) ``` Principal attributes accept user IDs or email addresses, resolving emails to user IDs automatically: diff --git a/python/lib/sift_client/_tests/resources/test_resource_attributes.py b/python/lib/sift_client/_tests/resources/test_resource_attributes.py index e0d3af0de..f3dcd8eaf 100644 --- a/python/lib/sift_client/_tests/resources/test_resource_attributes.py +++ b/python/lib/sift_client/_tests/resources/test_resource_attributes.py @@ -6,7 +6,6 @@ tests and run without a backend. """ -from types import SimpleNamespace from unittest.mock import AsyncMock, MagicMock import pytest @@ -15,7 +14,6 @@ from sift_client.resources.access_control.resource_attributes import ResourceAttributesAPIAsync from sift_client.sift_types.resource_attribute import ( ResourceAttributeEntity, - ResourceAttributeEntityType, ResourceAttributeEnumValue, ResourceAttributeKey, ResourceAttributeKeyUpdate, @@ -48,10 +46,6 @@ def _enum(eid: str, name: str) -> ResourceAttributeEnumValue: ) -def _matched_resource(resource_id: str): - return SimpleNamespace(_id_or_error=resource_id) - - class TestKeysGetOrCreate: @pytest.mark.asyncio async def test_returns_existing_without_creating(self): @@ -166,43 +160,12 @@ async def test_fetches_key_when_assigning_by_key_id(self): assert kwargs["enum_value_ids"] == ["e_a"] @pytest.mark.asyncio - async def test_resolves_resource_ids_to_supported_entity_type(self): + async def test_bare_string_resource_raises_type_error(self): api = _api() - api.assignments._low_level_client.batch_create_resource_attributes = AsyncMock( - return_value=[] - ) - api.client.async_.assets.list_ = AsyncMock(return_value=[]) - api.client.async_.channels.list_ = AsyncMock(return_value=[_matched_resource("ch1")]) - api.client.async_.runs.list_ = AsyncMock(return_value=[]) - - await api.assignments.create(_key(), ["ch1", "ch1"], value=["e_a"]) - - api.client.async_.assets.list_.assert_awaited_once_with( - asset_ids=["ch1"], include_archived=True - ) - api.client.async_.channels.list_.assert_awaited_once_with(channel_ids=["ch1"]) - api.client.async_.runs.list_.assert_awaited_once_with( - run_ids=["ch1"], include_archived=True - ) - kwargs = api.assignments._low_level_client.batch_create_resource_attributes.call_args.kwargs - entities = kwargs["entities"] - assert entities[0].entity_type == ResourceAttributeEntityType.CHANNEL - assert entities[0].entity_id == "ch1" - assert entities[1].entity_type == ResourceAttributeEntityType.CHANNEL - assert entities[1].entity_id == "ch1" - - @pytest.mark.asyncio - async def test_rejects_unknown_resource_ids(self): - api = _api() - api.assignments._low_level_client.batch_create_resource_attributes = AsyncMock( - return_value=[] - ) - api.client.async_.assets.list_ = AsyncMock(return_value=[]) - api.client.async_.channels.list_ = AsyncMock(return_value=[]) - api.client.async_.runs.list_ = AsyncMock(return_value=[]) + api.assignments._low_level_client.batch_create_resource_attributes = AsyncMock() - with pytest.raises(ValueError, match="Could not resolve resource ID"): - await api.assignments.create(_key(), ["missing"], value=["e_a"]) + with pytest.raises(TypeError, match="Cannot resolve resource"): + await api.assignments.create(_key(), ["ch1"], value=["e_a"]) api.assignments._low_level_client.batch_create_resource_attributes.assert_not_called() diff --git a/python/lib/sift_client/resources/access_control/resource_attributes.py b/python/lib/sift_client/resources/access_control/resource_attributes.py index 717ad0af6..c767e3720 100644 --- a/python/lib/sift_client/resources/access_control/resource_attributes.py +++ b/python/lib/sift_client/resources/access_control/resource_attributes.py @@ -1,12 +1,10 @@ from __future__ import annotations -import asyncio from typing import TYPE_CHECKING, Union from sift_client._internal.low_level_wrappers.resource_attributes import ( ResourceAttributesLowLevelClient, ) -from sift_client._internal.util.util import chunked from sift_client.resources._base import ResourceBase from sift_client.resources.access_control._common import ( attribute_value_kwargs, @@ -18,7 +16,6 @@ from sift_client.sift_types.resource_attribute import ( ResourceAttributeAssignment, ResourceAttributeEntity, - ResourceAttributeEntityType, ResourceAttributeEnumValue, ResourceAttributeKey, ResourceAttributeKeyUpdate, @@ -33,11 +30,7 @@ from sift_client.client import SiftClient -# Chunk size for the ID-resolution lookups that fan out to the assets/channels/runs -# list APIs. -_RESOLVE_BATCH_SIZE = 1000 - -ResourceLike = Union[ResourceAttributeEntity, Asset, Channel, Run, str] +ResourceLike = Union[ResourceAttributeEntity, Asset, Channel, Run] def _resolve_resource_object(resource: ResourceLike) -> ResourceAttributeEntity: @@ -52,8 +45,8 @@ def _resolve_resource_object(resource: ResourceLike) -> ResourceAttributeEntity: return ResourceAttributeEntity.for_run(resource._id_or_error) raise TypeError( f"Cannot resolve resource of type {type(resource).__name__}. Pass a supported resource " - "object, such as an Asset, Channel, or Run; a resource ID string; or a " - "ResourceAttributeEntity." + "object, such as an Asset, Channel, or Run, or a ResourceAttributeEntity built from " + "a resource ID." ) @@ -64,9 +57,9 @@ class ResourceAttributesAPIAsync(ResourceBase): resource is the "what" in an access decision. Create or fetch an attribute key via `keys`, define enum values via `enum_values` - when the key uses them, then assign a value to resources via `assignments`. For - currently supported resource types, you can pass existing ``Asset``, ``Channel``, - and ``Run`` objects or their IDs directly. + when the key uses them, then assign a value to resources via `assignments`. Pass + existing ``Asset``, ``Channel``, and ``Run`` objects directly, or build a + ``ResourceAttributeEntity`` from a resource ID. """ keys: ResourceAttributeKeysAPIAsync @@ -465,7 +458,7 @@ def __init__(self, sift_client: SiftClient): async def create( self, key: str | ResourceAttributeKey, - resources: list[ResourceAttributeEntity | Asset | Channel | Run | str], + resources: list[ResourceAttributeEntity | Asset | Channel | Run], *, value: ResourceAttributeValueLike, ) -> list[ResourceAttributeAssignment]: @@ -473,9 +466,9 @@ async def create( Args: key: The key or key ID to assign. Its ``value_type`` determines how ``value`` is interpreted. - resources: Resources to assign to. For currently supported resource types, pass - ``Asset``, ``Channel``, or ``Run`` objects, their IDs, or - ``ResourceAttributeEntity`` when you already know the resource type. + resources: Resources to assign to. Pass ``Asset``, ``Channel``, or ``Run`` + objects, or ``ResourceAttributeEntity`` (via ``for_asset`` / + ``for_channel`` / ``for_run``) when you only have a resource ID. value: For ``SET_OF_ENUM``, a list of enum values (or their IDs) that becomes the full set on each resource; for ``ENUM``, a single enum value; for ``BOOLEAN``, a bool; for ``NUMBER``, an int. @@ -488,7 +481,7 @@ async def create( key_cls=ResourceAttributeKey, getter=lambda key_id: self._low_level_client.get_key(key_id), ) - resolved = await self._resolve_resources(resources) + resolved = [_resolve_resource_object(resource) for resource in resources] create_kwargs = attribute_value_kwargs(resolved_key.value_type, value) created = await self._low_level_client.batch_create_resource_attributes( @@ -512,7 +505,7 @@ async def list_( self, *, key: str | ResourceAttributeKey | None = None, - resource: ResourceAttributeEntity | Asset | Channel | Run | str | None = None, + resource: ResourceAttributeEntity | Asset | Channel | Run | None = None, include_archived: bool = False, filter_query: str | None = None, order_by: str | None = None, @@ -524,8 +517,8 @@ async def list_( Args: key: Filter to assignments of this key. resource: Filter to assignments on this resource. Cannot be combined with - ``key``, ``filter_query``, or ``order_by``. Pass a resource object, - resource ID, or ``ResourceAttributeEntity``. + ``key``, ``filter_query``, or ``order_by``. Pass a resource object or + ``ResourceAttributeEntity``. include_archived: If True, include archived assignments. filter_query: Explicit CEL query. order_by: Field and direction to order by. @@ -545,7 +538,7 @@ async def list_( "resource cannot be combined with key, filter_query, or order_by; " "the by-resource listing does not support additional filters." ) - resolved = await self._resolve_resource(resource) + resolved = _resolve_resource_object(resource) attrs = await self._low_level_client.list_all_resource_attributes_by_entity( entity=resolved, include_archived=include_archived, @@ -586,88 +579,3 @@ async def unarchive(self, assignments: list[str | ResourceAttributeAssignment]) """ ids = [id_of(a) for a in assignments] await self._low_level_client.batch_unarchive_resource_attributes(ids) - - async def _resolve_resource(self, resource: ResourceLike) -> ResourceAttributeEntity: - return (await self._resolve_resources([resource]))[0] - - async def _resolve_resources( - self, resources: list[ResourceAttributeEntity | Asset | Channel | Run | str] - ) -> list[ResourceAttributeEntity]: - resolved: list[ResourceAttributeEntity | str] = [] - resource_ids: list[str] = [] - for resource in resources: - if isinstance(resource, str): - if not resource: - raise ValueError("Resource ID cannot be empty.") - resolved.append(resource) - resource_ids.append(resource) - else: - resolved.append(_resolve_resource_object(resource)) - - if not resource_ids: - return [ - resource for resource in resolved if isinstance(resource, ResourceAttributeEntity) - ] - - resources_by_id = await self._resolve_resource_ids(resource_ids) - return [ - resources_by_id[resource] if isinstance(resource, str) else resource - for resource in resolved - ] - - async def _resolve_resource_ids( - self, resource_ids: list[str] - ) -> dict[str, ResourceAttributeEntity]: - unique_ids = list(dict.fromkeys(resource_ids)) - matched_types: dict[str, set[ResourceAttributeEntityType]] = {} - for batch in chunked(unique_ids, _RESOLVE_BATCH_SIZE): - assets: list[Asset] - channels: list[Channel] - runs: list[Run] - assets, channels, runs = await asyncio.gather( - self.client.async_.assets.list_(asset_ids=batch, include_archived=True), - self.client.async_.channels.list_(channel_ids=batch), - self.client.async_.runs.list_(run_ids=batch, include_archived=True), - ) - for asset in assets: - matched_types.setdefault(asset._id_or_error, set()).add( - ResourceAttributeEntityType.ASSET - ) - for channel in channels: - matched_types.setdefault(channel._id_or_error, set()).add( - ResourceAttributeEntityType.CHANNEL - ) - for run in runs: - matched_types.setdefault(run._id_or_error, set()).add( - ResourceAttributeEntityType.RUN - ) - - ambiguous_ids = [ - resource_id - for resource_id, entity_types in matched_types.items() - if len(entity_types) > 1 - ] - if ambiguous_ids: - raise ValueError( - f"Resource ID {ambiguous_ids[0]!r} matched multiple currently supported " - "resource types." - ) - - missing_ids = [ - resource_id for resource_id in unique_ids if resource_id not in matched_types - ] - if missing_ids: - preview = ", ".join(repr(resource_id) for resource_id in missing_ids[:3]) - if len(missing_ids) > 3: - preview = f"{preview}, and {len(missing_ids) - 3} more" - raise ValueError( - f"Could not resolve resource ID(s) {preview} as currently supported resources " - "(asset, channel, or run)." - ) - - return { - resource_id: ResourceAttributeEntity( - entity_id=resource_id, entity_type=next(iter(entity_types)) - ) - for resource_id, entity_types in matched_types.items() - } diff --git a/python/lib/sift_client/resources/sync_stubs/__init__.pyi b/python/lib/sift_client/resources/sync_stubs/__init__.pyi index 5d529a537..4c3c07b23 100644 --- a/python/lib/sift_client/resources/sync_stubs/__init__.pyi +++ b/python/lib/sift_client/resources/sync_stubs/__init__.pyi @@ -2024,7 +2024,7 @@ class ResourceAttributeAssignmentsAPI: def create( self, key: str | ResourceAttributeKey, - resources: list[ResourceAttributeEntity | Asset | Channel | Run | str], + resources: list[ResourceAttributeEntity | Asset | Channel | Run], *, value: ResourceAttributeValueLike, ) -> list[ResourceAttributeAssignment]: @@ -2032,9 +2032,9 @@ class ResourceAttributeAssignmentsAPI: Args: key: The key or key ID to assign. Its ``value_type`` determines how ``value`` is interpreted. - resources: Resources to assign to. For currently supported resource types, pass - ``Asset``, ``Channel``, or ``Run`` objects, their IDs, or - ``ResourceAttributeEntity`` when you already know the resource type. + resources: Resources to assign to. Pass ``Asset``, ``Channel``, or ``Run`` + objects, or ``ResourceAttributeEntity`` (via ``for_asset`` / + ``for_channel`` / ``for_run``) when you only have a resource ID. value: For ``SET_OF_ENUM``, a list of enum values (or their IDs) that becomes the full set on each resource; for ``ENUM``, a single enum value; for ``BOOLEAN``, a bool; for ``NUMBER``, an int. @@ -2059,7 +2059,7 @@ class ResourceAttributeAssignmentsAPI: self, *, key: str | ResourceAttributeKey | None = None, - resource: ResourceAttributeEntity | Asset | Channel | Run | str | None = None, + resource: ResourceAttributeEntity | Asset | Channel | Run | None = None, include_archived: bool = False, filter_query: str | None = None, order_by: str | None = None, @@ -2071,8 +2071,8 @@ class ResourceAttributeAssignmentsAPI: Args: key: Filter to assignments of this key. resource: Filter to assignments on this resource. Cannot be combined with - ``key``, ``filter_query``, or ``order_by``. Pass a resource object, - resource ID, or ``ResourceAttributeEntity``. + ``key``, ``filter_query``, or ``order_by``. Pass a resource object or + ``ResourceAttributeEntity``. include_archived: If True, include archived assignments. filter_query: Explicit CEL query. order_by: Field and direction to order by. @@ -2370,9 +2370,9 @@ class ResourceAttributesAPI: resource is the "what" in an access decision. Create or fetch an attribute key via `keys`, define enum values via `enum_values` - when the key uses them, then assign a value to resources via `assignments`. For - currently supported resource types, you can pass existing ``Asset``, ``Channel``, - and ``Run`` objects or their IDs directly. + when the key uses them, then assign a value to resources via `assignments`. Pass + existing ``Asset``, ``Channel``, and ``Run`` objects directly, or build a + ``ResourceAttributeEntity`` from a resource ID. """ def __init__(self, sift_client: SiftClient): diff --git a/python/lib/sift_client/sift_types/resource_attribute.py b/python/lib/sift_client/sift_types/resource_attribute.py index 3d793159e..beaa02500 100644 --- a/python/lib/sift_client/sift_types/resource_attribute.py +++ b/python/lib/sift_client/sift_types/resource_attribute.py @@ -320,16 +320,16 @@ def list_enum_values( def assign_to( self, - resources: list[ResourceAttributeEntity | Asset | Channel | Run | str], + resources: list[ResourceAttributeEntity | Asset | Channel | Run], *, value: ResourceAttributeValueLike, ) -> list[ResourceAttributeAssignment]: """Assign a value to one or more resources for this key. Args: - resources: Resources to assign to. For currently supported resource types, pass - ``Asset``, ``Channel``, or ``Run`` objects, their IDs, or - ``ResourceAttributeEntity`` when you already know the resource type. + resources: Resources to assign to. Pass ``Asset``, ``Channel``, or ``Run`` + objects, or ``ResourceAttributeEntity`` (via ``for_asset`` / + ``for_channel`` / ``for_run``) when you only have a resource ID. value: The value to assign. For ``SET_OF_ENUM`` keys, a list of enum values (or their IDs); for ``ENUM`` keys, a single enum value; for ``BOOLEAN`` keys, a bool; for ``NUMBER`` keys, an int. For ``SET_OF_ENUM`` this From 7c27a7a2a3d35e2f0c741151b87cfb090c5f575a Mon Sep 17 00:00:00 2001 From: Wei Qi Lu Date: Fri, 10 Jul 2026 15:14:25 -0700 Subject: [PATCH 14/21] add PrincipalRef and require typed refs for attribute assignments --- python/CHANGELOG.md | 2 +- .../resources/test_principal_attributes.py | 66 +++++++++- .../sift_types/test_principal_attribute.py | 33 ++++- .../access_control/principal_attributes.py | 119 +++++++++++------- .../resources/sync_stubs/__init__.pyi | 33 ++--- python/lib/sift_client/sift_types/__init__.py | 2 + .../sift_types/principal_attribute.py | 33 +++-- 7 files changed, 217 insertions(+), 71 deletions(-) diff --git a/python/CHANGELOG.md b/python/CHANGELOG.md index 1bae9efb9..35bd6d1df 100644 --- a/python/CHANGELOG.md +++ b/python/CHANGELOG.md @@ -78,7 +78,7 @@ licenses = key.get_or_create_enum_values(["LICENSE_A", "LICENSE_B"]) key.assign_to([ResourceAttributeEntity.for_channel("channel-id")], value=licenses) ``` -Principal attributes accept user IDs or email addresses, resolving emails to user IDs automatically: +Principal attributes accept `User` objects, typed `PrincipalRef` references, or user email addresses (resolved to user IDs automatically). Use `PrincipalRef.user_group(...)` for user groups: ```python from sift_client.sift_types import PrincipalAttributeValueType diff --git a/python/lib/sift_client/_tests/resources/test_principal_attributes.py b/python/lib/sift_client/_tests/resources/test_principal_attributes.py index 0dbbfc52b..3989352de 100644 --- a/python/lib/sift_client/_tests/resources/test_principal_attributes.py +++ b/python/lib/sift_client/_tests/resources/test_principal_attributes.py @@ -13,8 +13,10 @@ from sift_client.sift_types.principal_attribute import ( PrincipalAttributeKey, PrincipalAttributeKeyUpdate, + PrincipalRef, PrincipalType, ) +from sift_client.util import cel_utils as cel def _api() -> PrincipalAttributesAPIAsync: @@ -43,12 +45,13 @@ async def test_fetches_key_when_assigning_by_key_id(self): api.assignments._low_level_client.get_key = AsyncMock(return_value=_key()) api.assignments._low_level_client.batch_create_values = AsyncMock(return_value=[]) - await api.assignments.create("pk1", ["u1"], value=["e_a"]) + await api.assignments.create("pk1", [PrincipalRef.user("u1")], value=["e_a"]) api.assignments._low_level_client.get_key.assert_awaited_once_with("pk1") kwargs = api.assignments._low_level_client.batch_create_values.call_args.kwargs assert kwargs["key_id"] == "pk1" assert kwargs["principal_ids"] == ["u1"] + assert kwargs["principal_type"] == PrincipalType.USER.value assert kwargs["enum_value_ids"] == ["e_a"] @pytest.mark.asyncio @@ -57,7 +60,9 @@ async def test_resolves_emails_via_users_api_and_keeps_raw_ids(self): api.client.async_.users.resolve_ids = AsyncMock(return_value={"alice@x.com": "u1"}) api.assignments._low_level_client.batch_create_values = AsyncMock(return_value=[]) - await api.assignments.create(_key(), ["alice@x.com", "raw_id"], value=["e_a"]) + await api.assignments.create( + _key(), ["alice@x.com", PrincipalRef.user("raw_id")], value=["e_a"] + ) api.client.async_.users.resolve_ids.assert_awaited_once_with(["alice@x.com"]) kwargs = api.assignments._low_level_client.batch_create_values.call_args.kwargs @@ -74,13 +79,41 @@ async def test_unresolvable_email_raises(self): await api.assignments.create(_key(), ["ghost@x.com"], value=["e_a"]) @pytest.mark.asyncio - async def test_email_with_non_user_principal_type_raises(self): + async def test_email_in_user_group_ref_raises(self): api = _api() with pytest.raises(ValueError, match="only supported for USER"): await api.assignments.create( - _key(), ["group@x.com"], value=["e_a"], principal_type=PrincipalType.USER_GROUP + _key(), [PrincipalRef.user_group("group@x.com")], value=["e_a"] ) + @pytest.mark.asyncio + async def test_bare_principal_id_raises_type_error(self): + api = _api() + api.assignments._low_level_client.batch_create_values = AsyncMock() + + with pytest.raises(TypeError, match="Cannot resolve principal"): + await api.assignments.create(_key(), ["u1"], value=["e_a"]) + + api.assignments._low_level_client.batch_create_values.assert_not_called() + + @pytest.mark.asyncio + async def test_mixed_principal_types_split_into_one_rpc_per_type(self): + api = _api() + api.assignments._low_level_client.batch_create_values = AsyncMock(return_value=[]) + + await api.assignments.create( + _key(), + [PrincipalRef.user("u1"), PrincipalRef.user_group("g1"), PrincipalRef.user("u2")], + value=["e_a"], + ) + + calls = api.assignments._low_level_client.batch_create_values.call_args_list + assert len(calls) == 2 + assert calls[0].kwargs["principal_ids"] == ["u1", "u2"] + assert calls[0].kwargs["principal_type"] == PrincipalType.USER.value + assert calls[1].kwargs["principal_ids"] == ["g1"] + assert calls[1].kwargs["principal_type"] == PrincipalType.USER_GROUP.value + class TestAssignmentsListRouting: @pytest.mark.asyncio @@ -103,6 +136,31 @@ async def test_uses_all_values_rpc_without_key(self): await api.assignments.list_() api.assignments._low_level_client.list_all_values.assert_awaited_once() + kwargs = api.assignments._low_level_client.list_all_values.call_args.kwargs + assert kwargs["principal_type"] == PrincipalType.USER.value + + @pytest.mark.asyncio + async def test_typed_principal_scopes_rpc_to_its_type(self): + api = _api() + api.assignments._low_level_client.list_all_values = AsyncMock(return_value=[]) + + await api.assignments.list_(principal=PrincipalRef.user_group("g1")) + + kwargs = api.assignments._low_level_client.list_all_values.call_args.kwargs + assert kwargs["principal_type"] == PrincipalType.USER_GROUP.value + assert kwargs["query_filter"] == cel.equals("principal_id", "g1") + + @pytest.mark.asyncio + async def test_conflicting_principal_type_raises(self): + api = _api() + api.assignments._low_level_client.list_all_values = AsyncMock(return_value=[]) + + with pytest.raises(ValueError, match="conflicts"): + await api.assignments.list_( + principal=PrincipalRef.user("u1"), principal_type=PrincipalType.USER_GROUP + ) + + api.assignments._low_level_client.list_all_values.assert_not_called() class TestKeysUpdate: diff --git a/python/lib/sift_client/_tests/sift_types/test_principal_attribute.py b/python/lib/sift_client/_tests/sift_types/test_principal_attribute.py index f0340e490..1a5febbcf 100644 --- a/python/lib/sift_client/_tests/sift_types/test_principal_attribute.py +++ b/python/lib/sift_client/_tests/sift_types/test_principal_attribute.py @@ -1,6 +1,7 @@ """Tests for sift_types principal attribute models.""" import pytest +from sift.common.type.v1.user_pb2 import User as UserProto from sift.principal_attributes.v1 import principal_attributes_pb2 as pa from sift_client.sift_types.principal_attribute import ( @@ -9,8 +10,10 @@ PrincipalAttributeKey, PrincipalAttributeKeyUpdate, PrincipalAttributeValueType, + PrincipalRef, PrincipalType, ) +from sift_client.sift_types.user import User def _key_proto() -> pa.PrincipalAttributeKey: @@ -106,16 +109,40 @@ def test_apply_client_cascades_to_nested_key_and_enum_value(self, mock_client): assert value.enum_value.client is mock_client +class TestPrincipalRef: + def test_user_from_id(self): + ref = PrincipalRef.user("u1") + assert ref.principal_id == "u1" + assert ref.principal_type == PrincipalType.USER + + def test_user_from_email(self): + ref = PrincipalRef.user("alice@x.com") + assert ref.principal_id == "alice@x.com" + assert ref.principal_type == PrincipalType.USER + + def test_user_from_user_object(self): + user = User._from_proto(UserProto(user_id="u1", user_name="alice@x.com")) + ref = PrincipalRef.user(user) + assert ref.principal_id == "u1" + assert ref.principal_type == PrincipalType.USER + + def test_user_group_from_id(self): + ref = PrincipalRef.user_group("g1") + assert ref.principal_id == "g1" + assert ref.principal_type == PrincipalType.USER_GROUP + + class TestPrincipalAttributeKeyConvenience: - def test_assign_to_defaults_to_user(self, mock_client): + def test_assign_to_forwards_to_assignments_create(self, mock_client): key = PrincipalAttributeKey._from_proto(_key_proto()) key._apply_client_to_instance(mock_client) mock_client.access_control.principal_attributes.assignments.create.return_value = [] + principals = [PrincipalRef.user("u1")] - key.assign_to(["u1"], value=["LIC_A"]) + key.assign_to(principals, value=["LIC_A"]) mock_client.access_control.principal_attributes.assignments.create.assert_called_once_with( - key, ["u1"], value=["LIC_A"], principal_type=PrincipalType.USER + key, principals, value=["LIC_A"] ) def test_client_required(self): diff --git a/python/lib/sift_client/resources/access_control/principal_attributes.py b/python/lib/sift_client/resources/access_control/principal_attributes.py index 711be5e0f..df9a85e25 100644 --- a/python/lib/sift_client/resources/access_control/principal_attributes.py +++ b/python/lib/sift_client/resources/access_control/principal_attributes.py @@ -18,8 +18,10 @@ PrincipalAttributeKeyUpdate, PrincipalAttributeValueLike, PrincipalAttributeValueType, + PrincipalRef, PrincipalType, ) +from sift_client.sift_types.user import User from sift_client.util import cel_utils as cel if TYPE_CHECKING: @@ -28,6 +30,22 @@ from sift_client.client import SiftClient +def _as_principal_ref(principal: PrincipalRef | User | str) -> PrincipalRef: + """Resolve a principal input to a typed reference; bare IDs are rejected.""" + if isinstance(principal, PrincipalRef): + return principal + if isinstance(principal, User): + return PrincipalRef.user(principal) + if isinstance(principal, str) and "@" in principal: + # An email names its own type: only user principals have emails. + return PrincipalRef.user(principal) + raise TypeError( + f"Cannot resolve principal {principal!r}. Pass PrincipalRef.user(...) or " + "PrincipalRef.user_group(...), a User object, or a user email address; a bare ID " + "does not say which kind of principal it refers to." + ) + + class PrincipalAttributesAPIAsync(ResourceBase): """High-level API for principal attributes. @@ -35,9 +53,9 @@ class PrincipalAttributesAPIAsync(ResourceBase): A principal is the "who" in an access decision, such as a user or user group. Create or fetch an attribute key via `keys`, define enum values via `enum_values` - when the key uses them, then assign a value to principals via `assignments`. User - principals accept either user IDs or email addresses; user-group principals use - user-group IDs. + when the key uses them, then assign a value to principals via `assignments`. Pass + ``User`` objects, ``PrincipalRef`` references, or user email addresses; use + ``PrincipalRef.user_group(...)`` for user groups. """ keys: PrincipalAttributeKeysAPIAsync @@ -441,22 +459,21 @@ def __init__(self, sift_client: SiftClient): async def create( self, key: str | PrincipalAttributeKey, - principals: list[str], + principals: list[PrincipalRef | User | str], *, value: PrincipalAttributeValueLike, - principal_type: PrincipalType = PrincipalType.USER, ) -> list[PrincipalAttributeAssignment]: """Assign a key's value to principals. Args: key: The key or key ID to assign. Its ``value_type`` determines how ``value`` is interpreted. - principals: Principal IDs. For ``USER`` principals, entries containing ``@`` are - treated as email addresses and resolved to user IDs. + principals: Principals to assign to. Pass ``PrincipalRef.user(...)`` / + ``PrincipalRef.user_group(...)`` references, ``User`` objects, or user + email addresses (resolved to user IDs automatically). Bare IDs are + rejected because they do not say which kind of principal they refer to. value: For ``SET_OF_ENUM``, a list of enum values (or their IDs) that becomes the full set on each principal; for ``ENUM``, a single enum value; for ``BOOLEAN``, a bool; for ``NUMBER``, an int. - principal_type: The kind of principal being assigned to. Defaults to ``USER``. Use - ``PrincipalType.USER_GROUP`` when assigning to user groups. Returns: The created assignments. @@ -466,15 +483,23 @@ async def create( key_cls=PrincipalAttributeKey, getter=lambda key_id: self._low_level_client.get_key(key_id), ) - resolved_ids = await self._resolve_principal_ids(principals, principal_type=principal_type) + refs = await self._resolve_email_refs([_as_principal_ref(p) for p in principals]) create_kwargs = attribute_value_kwargs(resolved_key.value_type, value) - created = await self._low_level_client.batch_create_values( - key_id=resolved_key._id_or_error, - principal_ids=resolved_ids, - principal_type=principal_type.value, - **create_kwargs, - ) + grouped: dict[PrincipalType, list[str]] = {} + for ref in refs: + grouped.setdefault(ref.principal_type, []).append(ref.principal_id) + + created: list[PrincipalAttributeAssignment] = [] + for principal_type, principal_ids in grouped.items(): + created.extend( + await self._low_level_client.batch_create_values( + key_id=resolved_key._id_or_error, + principal_ids=principal_ids, + principal_type=principal_type.value, + **create_kwargs, + ) + ) return self._apply_client_to_instances(created) async def get( @@ -501,8 +526,8 @@ async def list_( self, *, key: str | PrincipalAttributeKey | None = None, - principal: str | None = None, - principal_type: PrincipalType = PrincipalType.USER, + principal: PrincipalRef | User | str | None = None, + principal_type: PrincipalType | None = None, include_archived: bool = False, filter_query: str | None = None, order_by: str | None = None, @@ -513,9 +538,11 @@ async def list_( Args: key: Filter to assignments of this key. - principal: Filter to assignments for this principal. Use a user ID or email address - for users; use a user-group ID with ``PrincipalType.USER_GROUP`` for user groups. - principal_type: The kind of principal to list assignments for. Defaults to ``USER``. + principal: Filter to assignments for this principal. Pass a ``PrincipalRef``, + a ``User`` object, or a user email address. + principal_type: The kind of principal to list assignments for when + ``principal`` is not given. Defaults to ``USER``. When ``principal`` is + given, its own type is used and this must match it if set. include_archived: If True, include archived assignments. filter_query: Explicit CEL query. order_by: Field and direction to order by. @@ -524,13 +551,22 @@ async def list_( Returns: The matching assignments. + + Raises: + ValueError: If ``principal_type`` conflicts with the type of ``principal``. """ filter_parts = [] if principal is not None: - (resolved,) = await self._resolve_principal_ids( - [principal], principal_type=principal_type - ) - filter_parts.append(cel.equals("principal_id", resolved)) + (ref,) = await self._resolve_email_refs([_as_principal_ref(principal)]) + if principal_type is not None and principal_type != ref.principal_type: + raise ValueError( + f"principal_type {principal_type.name} conflicts with the principal's " + f"own type {ref.principal_type.name}." + ) + principal_type = ref.principal_type + filter_parts.append(cel.equals("principal_id", ref.principal_id)) + elif principal_type is None: + principal_type = PrincipalType.USER if filter_query: filter_parts.append(filter_query) query_filter = cel.and_(*filter_parts) or None @@ -586,27 +622,26 @@ async def unarchive( ids = [id_of(a) for a in assignments] await self._low_level_client.unarchive_values(ids, principal_type=principal_type.value) - async def _resolve_principal_ids( - self, principals: list[str], *, principal_type: PrincipalType - ) -> list[str]: - """Resolve a list of principals to IDs, treating user emails (``@``) as lookups.""" + async def _resolve_email_refs(self, refs: list[PrincipalRef]) -> list[PrincipalRef]: + """Resolve user emails (``@``) in principal references to user IDs.""" emails = [ - p - for p in principals - if principal_type == PrincipalType.USER and isinstance(p, str) and "@" in p + ref.principal_id + for ref in refs + if ref.principal_type == PrincipalType.USER and "@" in ref.principal_id ] email_to_id = await self.client.async_.users.resolve_ids(emails) if emails else {} - resolved: list[str] = [] - for principal in principals: - if principal in email_to_id: - resolved.append(email_to_id[principal]) - elif "@" in principal and principal_type != PrincipalType.USER: + resolved: list[PrincipalRef] = [] + for ref in refs: + if "@" not in ref.principal_id: + resolved.append(ref) + elif ref.principal_type != PrincipalType.USER: raise ValueError( - f"Email resolution is only supported for USER principals; got {principal!r} " - f"for principal_type {principal_type.name}. Pass a principal ID instead." + f"Email resolution is only supported for USER principals; got " + f"{ref.principal_id!r} for principal_type {ref.principal_type.name}. " + "Pass a principal ID instead." ) - elif principal_type == PrincipalType.USER and "@" in principal: - raise ValueError(f"No user found for email {principal!r}") + elif ref.principal_id not in email_to_id: + raise ValueError(f"No user found for email {ref.principal_id!r}") else: - resolved.append(principal) + resolved.append(PrincipalRef.user(email_to_id[ref.principal_id])) return resolved diff --git a/python/lib/sift_client/resources/sync_stubs/__init__.pyi b/python/lib/sift_client/resources/sync_stubs/__init__.pyi index 4c3c07b23..e939490c7 100644 --- a/python/lib/sift_client/resources/sync_stubs/__init__.pyi +++ b/python/lib/sift_client/resources/sync_stubs/__init__.pyi @@ -47,6 +47,7 @@ if TYPE_CHECKING: PrincipalAttributeKeyUpdate, PrincipalAttributeValueLike, PrincipalAttributeValueType, + PrincipalRef, PrincipalType, ) from sift_client.sift_types.report import Report, ReportUpdate @@ -1220,22 +1221,21 @@ class PrincipalAttributeAssignmentsAPI: def create( self, key: str | PrincipalAttributeKey, - principals: list[str], + principals: list[PrincipalRef | User | str], *, value: PrincipalAttributeValueLike, - principal_type: PrincipalType = PrincipalType.USER, ) -> list[PrincipalAttributeAssignment]: """Assign a key's value to principals. Args: key: The key or key ID to assign. Its ``value_type`` determines how ``value`` is interpreted. - principals: Principal IDs. For ``USER`` principals, entries containing ``@`` are - treated as email addresses and resolved to user IDs. + principals: Principals to assign to. Pass ``PrincipalRef.user(...)`` / + ``PrincipalRef.user_group(...)`` references, ``User`` objects, or user + email addresses (resolved to user IDs automatically). Bare IDs are + rejected because they do not say which kind of principal they refer to. value: For ``SET_OF_ENUM``, a list of enum values (or their IDs) that becomes the full set on each principal; for ``ENUM``, a single enum value; for ``BOOLEAN``, a bool; for ``NUMBER``, an int. - principal_type: The kind of principal being assigned to. Defaults to ``USER``. Use - ``PrincipalType.USER_GROUP`` when assigning to user groups. Returns: The created assignments. @@ -1260,8 +1260,8 @@ class PrincipalAttributeAssignmentsAPI: self, *, key: str | PrincipalAttributeKey | None = None, - principal: str | None = None, - principal_type: PrincipalType = PrincipalType.USER, + principal: PrincipalRef | User | str | None = None, + principal_type: PrincipalType | None = None, include_archived: bool = False, filter_query: str | None = None, order_by: str | None = None, @@ -1272,9 +1272,11 @@ class PrincipalAttributeAssignmentsAPI: Args: key: Filter to assignments of this key. - principal: Filter to assignments for this principal. Use a user ID or email address - for users; use a user-group ID with ``PrincipalType.USER_GROUP`` for user groups. - principal_type: The kind of principal to list assignments for. Defaults to ``USER``. + principal: Filter to assignments for this principal. Pass a ``PrincipalRef``, + a ``User`` object, or a user email address. + principal_type: The kind of principal to list assignments for when + ``principal`` is not given. Defaults to ``USER``. When ``principal`` is + given, its own type is used and this must match it if set. include_archived: If True, include archived assignments. filter_query: Explicit CEL query. order_by: Field and direction to order by. @@ -1283,6 +1285,9 @@ class PrincipalAttributeAssignmentsAPI: Returns: The matching assignments. + + Raises: + ValueError: If ``principal_type`` conflicts with the type of ``principal``. """ ... @@ -1578,9 +1583,9 @@ class PrincipalAttributesAPI: A principal is the "who" in an access decision, such as a user or user group. Create or fetch an attribute key via `keys`, define enum values via `enum_values` - when the key uses them, then assign a value to principals via `assignments`. User - principals accept either user IDs or email addresses; user-group principals use - user-group IDs. + when the key uses them, then assign a value to principals via `assignments`. Pass + ``User`` objects, ``PrincipalRef`` references, or user email addresses; use + ``PrincipalRef.user_group(...)`` for user groups. """ def __init__(self, sift_client: SiftClient): diff --git a/python/lib/sift_client/sift_types/__init__.py b/python/lib/sift_client/sift_types/__init__.py index edfaef3bc..5cb05a2ae 100644 --- a/python/lib/sift_client/sift_types/__init__.py +++ b/python/lib/sift_client/sift_types/__init__.py @@ -171,6 +171,7 @@ PrincipalAttributeKeyUpdate, PrincipalAttributeValueLike, PrincipalAttributeValueType, + PrincipalRef, PrincipalType, ) from sift_client.sift_types.report import Report, ReportRuleStatus, ReportRuleSummary, ReportUpdate @@ -262,6 +263,7 @@ "PrincipalAttributeKeyUpdate", "PrincipalAttributeValueLike", "PrincipalAttributeValueType", + "PrincipalRef", "PrincipalType", "Report", "ReportRuleStatus", diff --git a/python/lib/sift_client/sift_types/principal_attribute.py b/python/lib/sift_client/sift_types/principal_attribute.py index 718ed6364..8c29fd034 100644 --- a/python/lib/sift_client/sift_types/principal_attribute.py +++ b/python/lib/sift_client/sift_types/principal_attribute.py @@ -18,9 +18,11 @@ from enum import Enum from typing import TYPE_CHECKING, Union +from pydantic import BaseModel from sift.principal_attributes.v1 import principal_attributes_pb2 as pa_pb from sift_client.sift_types._base import BaseType, ModelUpdate +from sift_client.sift_types.user import User if TYPE_CHECKING: from sift_client.client import SiftClient @@ -42,6 +44,25 @@ class PrincipalType(Enum): USER_GROUP = pa_pb.PRINCIPAL_ATTRIBUTE_PRINCIPAL_TYPE_USER_GROUP +class PrincipalRef(BaseModel): + """Typed reference to a principal, naming both the ID and the kind of principal.""" + + principal_id: str + principal_type: PrincipalType + + @classmethod + def user(cls, user: str | User) -> PrincipalRef: + """Reference a user by ID, email address, or ``User`` object.""" + if isinstance(user, User): + return cls(principal_id=user._id_or_error, principal_type=PrincipalType.USER) + return cls(principal_id=user, principal_type=PrincipalType.USER) + + @classmethod + def user_group(cls, user_group_id: str) -> PrincipalRef: + """Reference a user group by ID.""" + return cls(principal_id=user_group_id, principal_type=PrincipalType.USER_GROUP) + + class PrincipalAttributeEnumValue( BaseType[pa_pb.PrincipalAttributeEnumValue, "PrincipalAttributeEnumValue"] ): @@ -287,28 +308,26 @@ def list_enum_values( def assign_to( self, - principals: list[str], + principals: list[PrincipalRef | User | str], *, value: PrincipalAttributeValueLike, - principal_type: PrincipalType = PrincipalType.USER, ) -> list[PrincipalAttributeAssignment]: """Assign a value to one or more principals for this key. Args: - principals: Principal IDs to assign to. For ``USER`` principals, entries - containing ``@`` are treated as email addresses and resolved to user IDs. + principals: Principals to assign to. Pass ``PrincipalRef.user(...)`` / + ``PrincipalRef.user_group(...)`` references, ``User`` objects, or user + email addresses (resolved to user IDs automatically). value: The value to assign. For ``SET_OF_ENUM`` keys, a list of enum values (or their IDs); for ``ENUM`` keys, a single enum value; for ``BOOLEAN`` keys, a bool; for ``NUMBER`` keys, an int. For ``SET_OF_ENUM`` this replaces the full set on each principal. - principal_type: The kind of principal being assigned to. Defaults to ``USER``. Use - ``PrincipalType.USER_GROUP`` when assigning to user groups. Returns: The created assignments. """ return self.client.access_control.principal_attributes.assignments.create( - self, principals, value=value, principal_type=principal_type + self, principals, value=value ) def list_assignments( From ab6cd58561ba2edc1c7c409f46e68f2dad6608c8 Mon Sep 17 00:00:00 2001 From: Wei Qi Lu Date: Fri, 10 Jul 2026 15:26:07 -0700 Subject: [PATCH 15/21] document when assignment detail fields are populated --- python/lib/sift_client/sift_types/principal_attribute.py | 2 ++ python/lib/sift_client/sift_types/resource_attribute.py | 3 +++ 2 files changed, 5 insertions(+) diff --git a/python/lib/sift_client/sift_types/principal_attribute.py b/python/lib/sift_client/sift_types/principal_attribute.py index 8c29fd034..1c55433e0 100644 --- a/python/lib/sift_client/sift_types/principal_attribute.py +++ b/python/lib/sift_client/sift_types/principal_attribute.py @@ -150,7 +150,9 @@ class PrincipalAttributeAssignment( boolean_value: bool | None number_value: int | None key: PrincipalAttributeKey | None + """Full key details. Always set in responses.""" enum_value: PrincipalAttributeEnumValue | None + """Full enum value details. Only set for enum values; ``value`` falls back to ``enum_value_id``.""" created_date: datetime | None created_by_user_id: str archived_date: datetime | None diff --git a/python/lib/sift_client/sift_types/resource_attribute.py b/python/lib/sift_client/sift_types/resource_attribute.py index beaa02500..e9ab26a1d 100644 --- a/python/lib/sift_client/sift_types/resource_attribute.py +++ b/python/lib/sift_client/sift_types/resource_attribute.py @@ -161,11 +161,14 @@ class ResourceAttributeAssignment(BaseType[ra_pb.ResourceAttribute, "ResourceAtt organization_id: str key_id: str entity: ResourceAttributeEntity | None + """The resource this value is assigned to. Always set in responses.""" enum_value_id: str | None boolean_value: bool | None number_value: int | None key: ResourceAttributeKey | None + """Full key details. Only set when the server includes them; use ``key_id`` otherwise.""" enum_value: ResourceAttributeEnumValue | None + """Full enum value details. Only set for enum values; ``value`` falls back to ``enum_value_id``.""" created_date: datetime | None created_by_user_id: str archived_date: datetime | None From e537a6588f18886eb19a982e6fab3278b062d225 Mon Sep 17 00:00:00 2001 From: Wei Qi Lu Date: Fri, 10 Jul 2026 15:35:41 -0700 Subject: [PATCH 16/21] document SET_OF_ENUM row-per-value shape --- python/CHANGELOG.md | 2 +- .../resources/access_control/principal_attributes.py | 5 ++++- .../resources/access_control/resource_attributes.py | 5 ++++- .../lib/sift_client/resources/sync_stubs/__init__.pyi | 10 ++++++++-- .../lib/sift_client/sift_types/principal_attribute.py | 8 ++++++-- .../lib/sift_client/sift_types/resource_attribute.py | 8 ++++++-- 6 files changed, 29 insertions(+), 9 deletions(-) diff --git a/python/CHANGELOG.md b/python/CHANGELOG.md index 35bd6d1df..7f58090d5 100644 --- a/python/CHANGELOG.md +++ b/python/CHANGELOG.md @@ -91,7 +91,7 @@ licenses = key.get_or_create_enum_values(["LICENSE_A"]) key.assign_to(["user@example.com"], value=licenses) ``` -Keys, enum values, and assignments are managed through the nested `keys`, `enum_values`, and `assignments` APIs on each side (for example `client.access_control.resource_attributes.assignments.list_()`), each supporting the standard create, get, list, update, and archive operations. For `SET_OF_ENUM` keys, an assignment replaces the full value set on each target. +Keys, enum values, and assignments are managed through the nested `keys`, `enum_values`, and `assignments` APIs on each side (for example `client.access_control.resource_attributes.assignments.list_()`), each supporting the standard create, get, list, update, and archive operations. For `SET_OF_ENUM` keys, an assignment replaces the full value set on each target, and each enum value in the set is returned as its own assignment. #### Users diff --git a/python/lib/sift_client/resources/access_control/principal_attributes.py b/python/lib/sift_client/resources/access_control/principal_attributes.py index df9a85e25..72cce7081 100644 --- a/python/lib/sift_client/resources/access_control/principal_attributes.py +++ b/python/lib/sift_client/resources/access_control/principal_attributes.py @@ -476,7 +476,8 @@ async def create( a bool; for ``NUMBER``, an int. Returns: - The created assignments. + The created assignments, one per enum value per principal for + ``SET_OF_ENUM`` keys. """ resolved_key = await resolve_key( key, @@ -536,6 +537,8 @@ async def list_( ) -> list[PrincipalAttributeAssignment]: """List principal attribute assignments. + For ``SET_OF_ENUM`` keys, each enum value is returned as its own assignment. + Args: key: Filter to assignments of this key. principal: Filter to assignments for this principal. Pass a ``PrincipalRef``, diff --git a/python/lib/sift_client/resources/access_control/resource_attributes.py b/python/lib/sift_client/resources/access_control/resource_attributes.py index c767e3720..03e243983 100644 --- a/python/lib/sift_client/resources/access_control/resource_attributes.py +++ b/python/lib/sift_client/resources/access_control/resource_attributes.py @@ -474,7 +474,8 @@ async def create( bool; for ``NUMBER``, an int. Returns: - The created assignments. + The created assignments, one per enum value per resource for + ``SET_OF_ENUM`` keys. """ resolved_key = await resolve_key( key, @@ -514,6 +515,8 @@ async def list_( ) -> list[ResourceAttributeAssignment]: """List resource attribute assignments. + For ``SET_OF_ENUM`` keys, each enum value is returned as its own assignment. + Args: key: Filter to assignments of this key. resource: Filter to assignments on this resource. Cannot be combined with diff --git a/python/lib/sift_client/resources/sync_stubs/__init__.pyi b/python/lib/sift_client/resources/sync_stubs/__init__.pyi index e939490c7..9df08f4d4 100644 --- a/python/lib/sift_client/resources/sync_stubs/__init__.pyi +++ b/python/lib/sift_client/resources/sync_stubs/__init__.pyi @@ -1238,7 +1238,8 @@ class PrincipalAttributeAssignmentsAPI: a bool; for ``NUMBER``, an int. Returns: - The created assignments. + The created assignments, one per enum value per principal for + ``SET_OF_ENUM`` keys. """ ... @@ -1270,6 +1271,8 @@ class PrincipalAttributeAssignmentsAPI: ) -> list[PrincipalAttributeAssignment]: """List principal attribute assignments. + For ``SET_OF_ENUM`` keys, each enum value is returned as its own assignment. + Args: key: Filter to assignments of this key. principal: Filter to assignments for this principal. Pass a ``PrincipalRef``, @@ -2045,7 +2048,8 @@ class ResourceAttributeAssignmentsAPI: bool; for ``NUMBER``, an int. Returns: - The created assignments. + The created assignments, one per enum value per resource for + ``SET_OF_ENUM`` keys. """ ... @@ -2073,6 +2077,8 @@ class ResourceAttributeAssignmentsAPI: ) -> list[ResourceAttributeAssignment]: """List resource attribute assignments. + For ``SET_OF_ENUM`` keys, each enum value is returned as its own assignment. + Args: key: Filter to assignments of this key. resource: Filter to assignments on this resource. Cannot be combined with diff --git a/python/lib/sift_client/sift_types/principal_attribute.py b/python/lib/sift_client/sift_types/principal_attribute.py index 1c55433e0..5d09dd8b9 100644 --- a/python/lib/sift_client/sift_types/principal_attribute.py +++ b/python/lib/sift_client/sift_types/principal_attribute.py @@ -140,7 +140,11 @@ def __str__(self) -> str: class PrincipalAttributeAssignment( BaseType[pa_pb.PrincipalAttributeValue, "PrincipalAttributeAssignment"] ): - """A single assignment of a principal attribute value to a principal.""" + """A single assignment of a principal attribute value to a principal. + + For ``SET_OF_ENUM`` keys, each assignment holds one enum value: a set of N values + on one principal is stored and returned as N assignments. + """ organization_id: str key_id: str @@ -326,7 +330,7 @@ def assign_to( replaces the full set on each principal. Returns: - The created assignments. + The created assignments, one per enum value for ``SET_OF_ENUM`` keys. """ return self.client.access_control.principal_attributes.assignments.create( self, principals, value=value diff --git a/python/lib/sift_client/sift_types/resource_attribute.py b/python/lib/sift_client/sift_types/resource_attribute.py index e9ab26a1d..926a58ea3 100644 --- a/python/lib/sift_client/sift_types/resource_attribute.py +++ b/python/lib/sift_client/sift_types/resource_attribute.py @@ -156,7 +156,11 @@ def __str__(self) -> str: class ResourceAttributeAssignment(BaseType[ra_pb.ResourceAttribute, "ResourceAttributeAssignment"]): - """A single assignment of a resource attribute value to a supported resource.""" + """A single assignment of a resource attribute value to a supported resource. + + For ``SET_OF_ENUM`` keys, each assignment holds one enum value: a set of N values + on one resource is stored and returned as N assignments. + """ organization_id: str key_id: str @@ -339,7 +343,7 @@ def assign_to( replaces the full set on each resource. Returns: - The created assignments. + The created assignments, one per enum value for ``SET_OF_ENUM`` keys. """ return self.client.access_control.resource_attributes.assignments.create( self, resources, value=value From 17cd36b88de9e5984a8f499f815b1f12c0ef40e4 Mon Sep 17 00:00:00 2001 From: Wei Qi Lu Date: Fri, 10 Jul 2026 15:48:20 -0700 Subject: [PATCH 17/21] expose standard time/user/description filters on attribute list methods --- .../resources/test_principal_attributes.py | 39 +++++++++++ .../resources/test_resource_attributes.py | 55 ++++++++++++++++ .../access_control/principal_attributes.py | 64 +++++++++++++++++++ .../access_control/resource_attributes.py | 48 ++++++++++++-- .../resources/sync_stubs/__init__.pyi | 61 +++++++++++++++++- 5 files changed, 258 insertions(+), 9 deletions(-) diff --git a/python/lib/sift_client/_tests/resources/test_principal_attributes.py b/python/lib/sift_client/_tests/resources/test_principal_attributes.py index 3989352de..6ec5e9586 100644 --- a/python/lib/sift_client/_tests/resources/test_principal_attributes.py +++ b/python/lib/sift_client/_tests/resources/test_principal_attributes.py @@ -4,6 +4,7 @@ keys/enum_values/assignments sub-resources. They are not integration tests. """ +from datetime import datetime, timezone from unittest.mock import AsyncMock, MagicMock import pytest @@ -163,6 +164,44 @@ async def test_conflicting_principal_type_raises(self): api.assignments._low_level_client.list_all_values.assert_not_called() +class TestListCommonFilters: + @pytest.mark.asyncio + async def test_keys_time_and_user_filters_compose(self): + api = _api() + api.keys._low_level_client.list_all_keys = AsyncMock(return_value=[]) + before = datetime(2026, 2, 1, tzinfo=timezone.utc) + + await api.keys.list_(modified_before=before, created_by="u1", description_contains="lic") + + query = api.keys._low_level_client.list_all_keys.call_args.kwargs["query_filter"] + assert cel.less_than("modified_date", before) in query + assert cel.equals("created_by_user_id", "u1") in query + assert cel.contains("description", "lic") in query + + @pytest.mark.asyncio + async def test_enum_values_description_filter_composes(self): + api = _api() + api.enum_values._low_level_client.list_all_enum_values = AsyncMock(return_value=[]) + + await api.enum_values.list_(_key(), description_contains="lic") + + query = api.enum_values._low_level_client.list_all_enum_values.call_args.kwargs[ + "query_filter" + ] + assert cel.contains("description", "lic") in query + + @pytest.mark.asyncio + async def test_assignments_created_filter_composes(self): + api = _api() + api.assignments._low_level_client.list_all_values = AsyncMock(return_value=[]) + after = datetime(2026, 1, 1, tzinfo=timezone.utc) + + await api.assignments.list_(created_after=after) + + kwargs = api.assignments._low_level_client.list_all_values.call_args.kwargs + assert cel.greater_than("created_date", after) in kwargs["query_filter"] + + class TestKeysUpdate: @pytest.mark.asyncio async def test_dict_update_is_validated_and_carries_key_id(self): diff --git a/python/lib/sift_client/_tests/resources/test_resource_attributes.py b/python/lib/sift_client/_tests/resources/test_resource_attributes.py index f3dcd8eaf..091d6566c 100644 --- a/python/lib/sift_client/_tests/resources/test_resource_attributes.py +++ b/python/lib/sift_client/_tests/resources/test_resource_attributes.py @@ -6,6 +6,7 @@ tests and run without a backend. """ +from datetime import datetime, timezone from unittest.mock import AsyncMock, MagicMock import pytest @@ -19,6 +20,7 @@ ResourceAttributeKeyUpdate, ResourceAttributeValueType, ) +from sift_client.util import cel_utils as cel def _api() -> ResourceAttributesAPIAsync: @@ -221,6 +223,59 @@ def _asset_proto(): return proto +class TestListCommonFilters: + @pytest.mark.asyncio + async def test_keys_created_and_description_filters_compose(self): + api = _api() + api.keys._low_level_client.list_all_keys = AsyncMock(return_value=[]) + after = datetime(2026, 1, 1, tzinfo=timezone.utc) + + await api.keys.list_(created_after=after, description_contains="lic") + + query = api.keys._low_level_client.list_all_keys.call_args.kwargs["query_filter"] + assert cel.greater_than("created_date", after) in query + assert cel.contains("description", "lic") in query + + @pytest.mark.asyncio + async def test_enum_values_created_filter_composes(self): + api = _api() + api.enum_values._low_level_client.list_all_enum_values = AsyncMock(return_value=[]) + after = datetime(2026, 1, 1, tzinfo=timezone.utc) + + await api.enum_values.list_(_key(), created_after=after) + + query = api.enum_values._low_level_client.list_all_enum_values.call_args.kwargs[ + "query_filter" + ] + assert cel.greater_than("created_date", after) in query + + @pytest.mark.asyncio + async def test_assignments_created_filters_compose(self): + api = _api() + api.assignments._low_level_client.list_all_resource_attributes = AsyncMock(return_value=[]) + before = datetime(2026, 2, 1, tzinfo=timezone.utc) + + await api.assignments.list_(created_before=before, created_by="u1") + + query = api.assignments._low_level_client.list_all_resource_attributes.call_args.kwargs[ + "query_filter" + ] + assert cel.less_than("created_date", before) in query + assert cel.equals("created_by_user_id", "u1") in query + + @pytest.mark.asyncio + async def test_resource_with_created_filter_raises(self): + api = _api() + api.assignments._low_level_client.list_all_resource_attributes_by_entity = AsyncMock() + + with pytest.raises(ValueError, match="cannot be combined"): + await api.assignments.list_( + resource=ResourceAttributeEntity.for_channel("ch1"), created_by="u1" + ) + + api.assignments._low_level_client.list_all_resource_attributes_by_entity.assert_not_called() + + class TestKeysUpdate: @pytest.mark.asyncio async def test_dict_update_is_validated_and_carries_key_id(self): diff --git a/python/lib/sift_client/resources/access_control/principal_attributes.py b/python/lib/sift_client/resources/access_control/principal_attributes.py index 72cce7081..cfd849b70 100644 --- a/python/lib/sift_client/resources/access_control/principal_attributes.py +++ b/python/lib/sift_client/resources/access_control/principal_attributes.py @@ -26,6 +26,7 @@ if TYPE_CHECKING: import re + from datetime import datetime from sift_client.client import SiftClient @@ -116,6 +117,13 @@ async def list_( name_contains: str | None = None, name_regex: str | re.Pattern | None = None, value_type: PrincipalAttributeValueType | None = None, + created_after: datetime | None = None, + created_before: datetime | None = None, + modified_after: datetime | None = None, + modified_before: datetime | None = None, + created_by: str | None = None, + modified_by: str | None = None, + description_contains: str | None = None, include_archived: bool = False, filter_query: str | None = None, order_by: str | None = None, @@ -130,6 +138,13 @@ async def list_( name_contains: Substring match on the display name. name_regex: Regex match on the display name. value_type: Filter to keys of this value type. + created_after: Filter to keys created after this datetime. + created_before: Filter to keys created before this datetime. + modified_after: Filter to keys modified after this datetime. + modified_before: Filter to keys modified before this datetime. + created_by: Filter to keys created by this user ID. + modified_by: Filter to keys last modified by this user ID. + description_contains: Substring match on the description. include_archived: If True, include archived keys. filter_query: Explicit CEL query. order_by: Field and direction to order by. @@ -149,6 +164,18 @@ async def list_( ) if value_type is not None: filter_parts.append(cel.equals("value_type", value_type.value)) + filter_parts.extend( + self._build_time_cel_filters( + created_after=created_after, + created_before=created_before, + modified_after=modified_after, + modified_before=modified_before, + created_by=created_by, + modified_by=modified_by, + ) + ) + if description_contains: + filter_parts.append(cel.contains("description", description_contains)) if filter_query: filter_parts.append(filter_query) @@ -336,6 +363,13 @@ async def list_( names: list[str] | None = None, name_contains: str | None = None, name_regex: str | re.Pattern | None = None, + created_after: datetime | None = None, + created_before: datetime | None = None, + modified_after: datetime | None = None, + modified_before: datetime | None = None, + created_by: str | None = None, + modified_by: str | None = None, + description_contains: str | None = None, include_archived: bool = False, filter_query: str | None = None, order_by: str | None = None, @@ -350,6 +384,13 @@ async def list_( names: Display names to filter by. name_contains: Substring match on the display name. name_regex: Regex match on the display name. + created_after: Filter to enum values created after this datetime. + created_before: Filter to enum values created before this datetime. + modified_after: Filter to enum values modified after this datetime. + modified_before: Filter to enum values modified before this datetime. + created_by: Filter to enum values created by this user ID. + modified_by: Filter to enum values last modified by this user ID. + description_contains: Substring match on the description. include_archived: If True, include archived enum values. filter_query: Explicit CEL query. order_by: Field and direction to order by. @@ -363,6 +404,18 @@ async def list_( filter_parts = self._build_name_cel_filters( name=name, names=names, name_contains=name_contains, name_regex=name_regex ) + filter_parts.extend( + self._build_time_cel_filters( + created_after=created_after, + created_before=created_before, + modified_after=modified_after, + modified_before=modified_before, + created_by=created_by, + modified_by=modified_by, + ) + ) + if description_contains: + filter_parts.append(cel.contains("description", description_contains)) if filter_query: filter_parts.append(filter_query) values = await self._low_level_client.list_all_enum_values( @@ -529,6 +582,9 @@ async def list_( key: str | PrincipalAttributeKey | None = None, principal: PrincipalRef | User | str | None = None, principal_type: PrincipalType | None = None, + created_after: datetime | None = None, + created_before: datetime | None = None, + created_by: str | None = None, include_archived: bool = False, filter_query: str | None = None, order_by: str | None = None, @@ -546,6 +602,9 @@ async def list_( principal_type: The kind of principal to list assignments for when ``principal`` is not given. Defaults to ``USER``. When ``principal`` is given, its own type is used and this must match it if set. + created_after: Filter to assignments created after this datetime. + created_before: Filter to assignments created before this datetime. + created_by: Filter to assignments created by this user ID. include_archived: If True, include archived assignments. filter_query: Explicit CEL query. order_by: Field and direction to order by. @@ -570,6 +629,11 @@ async def list_( filter_parts.append(cel.equals("principal_id", ref.principal_id)) elif principal_type is None: principal_type = PrincipalType.USER + filter_parts.extend( + self._build_time_cel_filters( + created_after=created_after, created_before=created_before, created_by=created_by + ) + ) if filter_query: filter_parts.append(filter_query) query_filter = cel.and_(*filter_parts) or None diff --git a/python/lib/sift_client/resources/access_control/resource_attributes.py b/python/lib/sift_client/resources/access_control/resource_attributes.py index 03e243983..d12134953 100644 --- a/python/lib/sift_client/resources/access_control/resource_attributes.py +++ b/python/lib/sift_client/resources/access_control/resource_attributes.py @@ -27,6 +27,7 @@ if TYPE_CHECKING: import re + from datetime import datetime from sift_client.client import SiftClient @@ -120,6 +121,9 @@ async def list_( name_contains: str | None = None, name_regex: str | re.Pattern | None = None, value_type: ResourceAttributeValueType | None = None, + created_after: datetime | None = None, + created_before: datetime | None = None, + description_contains: str | None = None, include_archived: bool = False, filter_query: str | None = None, order_by: str | None = None, @@ -128,12 +132,17 @@ async def list_( ) -> list[ResourceAttributeKey]: """List resource attribute keys with optional filtering. + The service does not yet support filtering keys by modified date or user. + Args: name: Exact display name of the key. names: Display names to filter by. name_contains: Substring match on the display name. name_regex: Regex match on the display name. value_type: Filter to keys of this value type. + created_after: Filter to keys created after this datetime. + created_before: Filter to keys created before this datetime. + description_contains: Substring match on the description. include_archived: If True, include archived keys. filter_query: Explicit CEL query. order_by: Field and direction to order by. @@ -150,6 +159,11 @@ async def list_( ) if value_type is not None: filter_parts.append(cel.equals("key_type", value_type.value)) + filter_parts.extend( + self._build_time_cel_filters(created_after=created_after, created_before=created_before) + ) + if description_contains: + filter_parts.append(cel.contains("description", description_contains)) if filter_query: filter_parts.append(filter_query) @@ -335,6 +349,8 @@ async def list_( names: list[str] | None = None, name_contains: str | None = None, name_regex: str | re.Pattern | None = None, + created_after: datetime | None = None, + created_before: datetime | None = None, include_archived: bool = False, filter_query: str | None = None, order_by: str | None = None, @@ -343,12 +359,17 @@ async def list_( ) -> list[ResourceAttributeEnumValue]: """List the enum values defined for a key. + The service does not yet support filtering enum values by description, + modified date, or user. + Args: key: The key or key ID to list enum values for. name: Exact display name of the enum value. names: Display names to filter by. name_contains: Substring match on the display name. name_regex: Regex match on the display name. + created_after: Filter to enum values created after this datetime. + created_before: Filter to enum values created before this datetime. include_archived: If True, include archived enum values. filter_query: Explicit CEL query. order_by: Field and direction to order by. @@ -362,6 +383,9 @@ async def list_( filter_parts = self._build_name_cel_filters( name=name, names=names, name_contains=name_contains, name_regex=name_regex ) + filter_parts.extend( + self._build_time_cel_filters(created_after=created_after, created_before=created_before) + ) if filter_query: filter_parts.append(filter_query) values = await self._low_level_client.list_all_enum_values( @@ -507,6 +531,9 @@ async def list_( *, key: str | ResourceAttributeKey | None = None, resource: ResourceAttributeEntity | Asset | Channel | Run | None = None, + created_after: datetime | None = None, + created_before: datetime | None = None, + created_by: str | None = None, include_archived: bool = False, filter_query: str | None = None, order_by: str | None = None, @@ -520,8 +547,11 @@ async def list_( Args: key: Filter to assignments of this key. resource: Filter to assignments on this resource. Cannot be combined with - ``key``, ``filter_query``, or ``order_by``. Pass a resource object or + the other filter arguments. Pass a resource object or ``ResourceAttributeEntity``. + created_after: Filter to assignments created after this datetime. + created_before: Filter to assignments created before this datetime. + created_by: Filter to assignments created by this user ID. include_archived: If True, include archived assignments. filter_query: Explicit CEL query. order_by: Field and direction to order by. @@ -532,14 +562,15 @@ async def list_( The matching assignments. Raises: - ValueError: If ``resource`` is combined with ``key``, ``filter_query``, or - ``order_by``, which the by-resource listing does not support. + ValueError: If ``resource`` is combined with other filter arguments, which + the by-resource listing does not support. """ if resource is not None: - if key is not None or filter_query is not None or order_by is not None: + others = (key, created_after, created_before, created_by, filter_query, order_by) + if any(other is not None for other in others): raise ValueError( - "resource cannot be combined with key, filter_query, or order_by; " - "the by-resource listing does not support additional filters." + "resource cannot be combined with other filters; the by-resource " + "listing does not support them." ) resolved = _resolve_resource_object(resource) attrs = await self._low_level_client.list_all_resource_attributes_by_entity( @@ -553,6 +584,11 @@ async def list_( filter_parts = [] if key is not None: filter_parts.append(cel.equals("resource_attribute_key_id", id_of(key))) + filter_parts.extend( + self._build_time_cel_filters( + created_after=created_after, created_before=created_before, created_by=created_by + ) + ) if filter_query: filter_parts.append(filter_query) diff --git a/python/lib/sift_client/resources/sync_stubs/__init__.pyi b/python/lib/sift_client/resources/sync_stubs/__init__.pyi index 9df08f4d4..1ae13eb3f 100644 --- a/python/lib/sift_client/resources/sync_stubs/__init__.pyi +++ b/python/lib/sift_client/resources/sync_stubs/__init__.pyi @@ -1263,6 +1263,9 @@ class PrincipalAttributeAssignmentsAPI: key: str | PrincipalAttributeKey | None = None, principal: PrincipalRef | User | str | None = None, principal_type: PrincipalType | None = None, + created_after: datetime | None = None, + created_before: datetime | None = None, + created_by: str | None = None, include_archived: bool = False, filter_query: str | None = None, order_by: str | None = None, @@ -1280,6 +1283,9 @@ class PrincipalAttributeAssignmentsAPI: principal_type: The kind of principal to list assignments for when ``principal`` is not given. Defaults to ``USER``. When ``principal`` is given, its own type is used and this must match it if set. + created_after: Filter to assignments created after this datetime. + created_before: Filter to assignments created before this datetime. + created_by: Filter to assignments created by this user ID. include_archived: If True, include archived assignments. filter_query: Explicit CEL query. order_by: Field and direction to order by. @@ -1381,6 +1387,13 @@ class PrincipalAttributeEnumValuesAPI: names: list[str] | None = None, name_contains: str | None = None, name_regex: str | re.Pattern | None = None, + created_after: datetime | None = None, + created_before: datetime | None = None, + modified_after: datetime | None = None, + modified_before: datetime | None = None, + created_by: str | None = None, + modified_by: str | None = None, + description_contains: str | None = None, include_archived: bool = False, filter_query: str | None = None, order_by: str | None = None, @@ -1395,6 +1408,13 @@ class PrincipalAttributeEnumValuesAPI: names: Display names to filter by. name_contains: Substring match on the display name. name_regex: Regex match on the display name. + created_after: Filter to enum values created after this datetime. + created_before: Filter to enum values created before this datetime. + modified_after: Filter to enum values modified after this datetime. + modified_before: Filter to enum values modified before this datetime. + created_by: Filter to enum values created by this user ID. + modified_by: Filter to enum values last modified by this user ID. + description_contains: Substring match on the description. include_archived: If True, include archived enum values. filter_query: Explicit CEL query. order_by: Field and direction to order by. @@ -1527,6 +1547,13 @@ class PrincipalAttributeKeysAPI: name_contains: str | None = None, name_regex: str | re.Pattern | None = None, value_type: PrincipalAttributeValueType | None = None, + created_after: datetime | None = None, + created_before: datetime | None = None, + modified_after: datetime | None = None, + modified_before: datetime | None = None, + created_by: str | None = None, + modified_by: str | None = None, + description_contains: str | None = None, include_archived: bool = False, filter_query: str | None = None, order_by: str | None = None, @@ -1541,6 +1568,13 @@ class PrincipalAttributeKeysAPI: name_contains: Substring match on the display name. name_regex: Regex match on the display name. value_type: Filter to keys of this value type. + created_after: Filter to keys created after this datetime. + created_before: Filter to keys created before this datetime. + modified_after: Filter to keys modified after this datetime. + modified_before: Filter to keys modified before this datetime. + created_by: Filter to keys created by this user ID. + modified_by: Filter to keys last modified by this user ID. + description_contains: Substring match on the description. include_archived: If True, include archived keys. filter_query: Explicit CEL query. order_by: Field and direction to order by. @@ -2069,6 +2103,9 @@ class ResourceAttributeAssignmentsAPI: *, key: str | ResourceAttributeKey | None = None, resource: ResourceAttributeEntity | Asset | Channel | Run | None = None, + created_after: datetime | None = None, + created_before: datetime | None = None, + created_by: str | None = None, include_archived: bool = False, filter_query: str | None = None, order_by: str | None = None, @@ -2082,8 +2119,11 @@ class ResourceAttributeAssignmentsAPI: Args: key: Filter to assignments of this key. resource: Filter to assignments on this resource. Cannot be combined with - ``key``, ``filter_query``, or ``order_by``. Pass a resource object or + the other filter arguments. Pass a resource object or ``ResourceAttributeEntity``. + created_after: Filter to assignments created after this datetime. + created_before: Filter to assignments created before this datetime. + created_by: Filter to assignments created by this user ID. include_archived: If True, include archived assignments. filter_query: Explicit CEL query. order_by: Field and direction to order by. @@ -2094,8 +2134,8 @@ class ResourceAttributeAssignmentsAPI: The matching assignments. Raises: - ValueError: If ``resource`` is combined with ``key``, ``filter_query``, or - ``order_by``, which the by-resource listing does not support. + ValueError: If ``resource`` is combined with other filter arguments, which + the by-resource listing does not support. """ ... @@ -2180,6 +2220,8 @@ class ResourceAttributeEnumValuesAPI: names: list[str] | None = None, name_contains: str | None = None, name_regex: str | re.Pattern | None = None, + created_after: datetime | None = None, + created_before: datetime | None = None, include_archived: bool = False, filter_query: str | None = None, order_by: str | None = None, @@ -2188,12 +2230,17 @@ class ResourceAttributeEnumValuesAPI: ) -> list[ResourceAttributeEnumValue]: """List the enum values defined for a key. + The service does not yet support filtering enum values by description, + modified date, or user. + Args: key: The key or key ID to list enum values for. name: Exact display name of the enum value. names: Display names to filter by. name_contains: Substring match on the display name. name_regex: Regex match on the display name. + created_after: Filter to enum values created after this datetime. + created_before: Filter to enum values created before this datetime. include_archived: If True, include archived enum values. filter_query: Explicit CEL query. order_by: Field and direction to order by. @@ -2322,6 +2369,9 @@ class ResourceAttributeKeysAPI: name_contains: str | None = None, name_regex: str | re.Pattern | None = None, value_type: ResourceAttributeValueType | None = None, + created_after: datetime | None = None, + created_before: datetime | None = None, + description_contains: str | None = None, include_archived: bool = False, filter_query: str | None = None, order_by: str | None = None, @@ -2330,12 +2380,17 @@ class ResourceAttributeKeysAPI: ) -> list[ResourceAttributeKey]: """List resource attribute keys with optional filtering. + The service does not yet support filtering keys by modified date or user. + Args: name: Exact display name of the key. names: Display names to filter by. name_contains: Substring match on the display name. name_regex: Regex match on the display name. value_type: Filter to keys of this value type. + created_after: Filter to keys created after this datetime. + created_before: Filter to keys created before this datetime. + description_contains: Substring match on the description. include_archived: If True, include archived keys. filter_query: Explicit CEL query. order_by: Field and direction to order by. From 6920dd64287fc30bc99acc52021f11f507482c95 Mon Sep 17 00:00:00 2001 From: Wei Qi Lu Date: Fri, 10 Jul 2026 15:52:03 -0700 Subject: [PATCH 18/21] document access_control namespace scope --- python/lib/sift_client/resources/access_control/__init__.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/python/lib/sift_client/resources/access_control/__init__.py b/python/lib/sift_client/resources/access_control/__init__.py index da8d45b44..3eff1b576 100644 --- a/python/lib/sift_client/resources/access_control/__init__.py +++ b/python/lib/sift_client/resources/access_control/__init__.py @@ -3,6 +3,9 @@ Access-control APIs configure who can access what in Sift. In these APIs, a principal is the "who" and a resource is the "what" that access applies to. +This namespace currently covers attribute management. Policies and roles will +join it as they are added to the client. + Use ``client.access_control`` for synchronous APIs and ``client.async_.access_control`` for asynchronous APIs. """ From 5ff9f0f43c325479e7a40bdc0fc8d97b34543b71 Mon Sep 17 00:00:00 2001 From: Wei Qi Lu Date: Fri, 10 Jul 2026 16:58:05 -0700 Subject: [PATCH 19/21] resolve user emails case-insensitively with exact-match fast path --- .../_tests/resources/test_users.py | 34 +++++++++++++++++++ python/lib/sift_client/resources/users.py | 29 ++++++++++++++-- 2 files changed, 60 insertions(+), 3 deletions(-) diff --git a/python/lib/sift_client/_tests/resources/test_users.py b/python/lib/sift_client/_tests/resources/test_users.py index bbf0f44e9..a5762208d 100644 --- a/python/lib/sift_client/_tests/resources/test_users.py +++ b/python/lib/sift_client/_tests/resources/test_users.py @@ -109,6 +109,40 @@ async def test_deduplicates_emails(self): kwargs = api._low_level_client.list_all_active_users.call_args.kwargs assert kwargs["query_filter"] == cel.in_("name", ["alice@x.com"]) + @pytest.mark.asyncio + async def test_no_fallback_call_when_all_emails_match_exactly(self): + api = _api() + api._low_level_client.list_all_active_users = AsyncMock( + return_value=[_user("u1", "alice@x.com")] + ) + + await api.resolve_ids(["alice@x.com"]) + + assert api._low_level_client.list_all_active_users.await_count == 1 + + @pytest.mark.asyncio + async def test_resolves_case_insensitively_when_exact_match_misses(self): + api = _api() + # First call is the exact name filter (misses); the second lists all users + # for the case-insensitive fallback. + api._low_level_client.list_all_active_users = AsyncMock( + side_effect=[[], [_user("u1", "alice@x.com")]] + ) + + resolved = await api.resolve_ids(["Alice@X.com"]) + + assert resolved == {"Alice@X.com": "u1"} + + @pytest.mark.asyncio + async def test_ambiguous_case_insensitive_match_raises(self): + api = _api() + api._low_level_client.list_all_active_users = AsyncMock( + side_effect=[[], [_user("u1", "alice@x.com"), _user("u2", "Alice@x.com")]] + ) + + with pytest.raises(ValueError, match="Multiple users match"): + await api.resolve_ids(["ALICE@X.COM"]) + class TestGet: @pytest.mark.asyncio diff --git a/python/lib/sift_client/resources/users.py b/python/lib/sift_client/resources/users.py index d7a6ec131..c598b637c 100644 --- a/python/lib/sift_client/resources/users.py +++ b/python/lib/sift_client/resources/users.py @@ -112,14 +112,37 @@ async def find(self, **kwargs) -> User | None: async def resolve_ids(self, emails: list[str]) -> dict[str, str]: """Resolve user login emails (their user names) to user IDs. - Returns a mapping of email to user ID for the emails that were found. Emails - with no matching user are omitted. + Matching is case-insensitive. Login names are stored and compared + case-sensitively, so emails that miss on exact casing fall back to a + case-insensitive match against the full user list. + + Returns a mapping of email (as passed) to user ID for the emails that were + found. Emails with no matching user are omitted. Args: emails: The login emails to resolve. + + Raises: + ValueError: If an email matches multiple users case-insensitively. """ wanted = list(dict.fromkeys(email for email in emails if email)) if not wanted: return {} users = await self.list_(names=wanted) - return {user.name: user._id_or_error for user in users} + by_name = {user.name: user._id_or_error for user in users} + resolved = {email: by_name[email] for email in wanted if email in by_name} + + missing = [email for email in wanted if email not in resolved] + if missing: + folded_to_email = {email.casefold(): email for email in missing} + matches: dict[str, list[str]] = {} + for user in await self.list_(): + email = folded_to_email.get(user.name.casefold()) + if email is not None: + matches.setdefault(email, []).append(user._id_or_error) + for email, user_ids in matches.items(): + if len(set(user_ids)) > 1: + raise ValueError(f"Multiple users match email {email!r} case-insensitively.") + resolved[email] = user_ids[0] + + return {email: resolved[email] for email in wanted if email in resolved} From 68ccd5e9405b703c09d99e58c34d7ce3e40c7884 Mon Sep 17 00:00:00 2001 From: Wei Qi Lu Date: Fri, 10 Jul 2026 17:01:50 -0700 Subject: [PATCH 20/21] resolve inactive users by email in resolve_ids --- .../_tests/resources/test_users.py | 24 +++++++------------ python/lib/sift_client/resources/users.py | 7 +++--- 2 files changed, 13 insertions(+), 18 deletions(-) diff --git a/python/lib/sift_client/_tests/resources/test_users.py b/python/lib/sift_client/_tests/resources/test_users.py index a5762208d..79ace65d0 100644 --- a/python/lib/sift_client/_tests/resources/test_users.py +++ b/python/lib/sift_client/_tests/resources/test_users.py @@ -81,9 +81,7 @@ class TestResolveIds: @pytest.mark.asyncio async def test_maps_emails_and_omits_missing(self): api = _api() - api._low_level_client.list_all_active_users = AsyncMock( - return_value=[_user("u1", "alice@x.com")] - ) + api._low_level_client.list_all_users = AsyncMock(return_value=[_user("u1", "alice@x.com")]) resolved = await api.resolve_ids(["alice@x.com", "ghost@x.com"]) @@ -92,40 +90,36 @@ async def test_maps_emails_and_omits_missing(self): @pytest.mark.asyncio async def test_empty_input_makes_no_call(self): api = _api() - api._low_level_client.list_all_active_users = AsyncMock() + api._low_level_client.list_all_users = AsyncMock() assert await api.resolve_ids([]) == {} - api._low_level_client.list_all_active_users.assert_not_called() + api._low_level_client.list_all_users.assert_not_called() @pytest.mark.asyncio async def test_deduplicates_emails(self): api = _api() - api._low_level_client.list_all_active_users = AsyncMock( - return_value=[_user("u1", "alice@x.com")] - ) + api._low_level_client.list_all_users = AsyncMock(return_value=[_user("u1", "alice@x.com")]) await api.resolve_ids(["alice@x.com", "alice@x.com"]) - kwargs = api._low_level_client.list_all_active_users.call_args.kwargs + kwargs = api._low_level_client.list_all_users.call_args.kwargs assert kwargs["query_filter"] == cel.in_("name", ["alice@x.com"]) @pytest.mark.asyncio async def test_no_fallback_call_when_all_emails_match_exactly(self): api = _api() - api._low_level_client.list_all_active_users = AsyncMock( - return_value=[_user("u1", "alice@x.com")] - ) + api._low_level_client.list_all_users = AsyncMock(return_value=[_user("u1", "alice@x.com")]) await api.resolve_ids(["alice@x.com"]) - assert api._low_level_client.list_all_active_users.await_count == 1 + assert api._low_level_client.list_all_users.await_count == 1 @pytest.mark.asyncio async def test_resolves_case_insensitively_when_exact_match_misses(self): api = _api() # First call is the exact name filter (misses); the second lists all users # for the case-insensitive fallback. - api._low_level_client.list_all_active_users = AsyncMock( + api._low_level_client.list_all_users = AsyncMock( side_effect=[[], [_user("u1", "alice@x.com")]] ) @@ -136,7 +130,7 @@ async def test_resolves_case_insensitively_when_exact_match_misses(self): @pytest.mark.asyncio async def test_ambiguous_case_insensitive_match_raises(self): api = _api() - api._low_level_client.list_all_active_users = AsyncMock( + api._low_level_client.list_all_users = AsyncMock( side_effect=[[], [_user("u1", "alice@x.com"), _user("u2", "Alice@x.com")]] ) diff --git a/python/lib/sift_client/resources/users.py b/python/lib/sift_client/resources/users.py index c598b637c..8f0087940 100644 --- a/python/lib/sift_client/resources/users.py +++ b/python/lib/sift_client/resources/users.py @@ -114,7 +114,8 @@ async def resolve_ids(self, emails: list[str]) -> dict[str, str]: Matching is case-insensitive. Login names are stored and compared case-sensitively, so emails that miss on exact casing fall back to a - case-insensitive match against the full user list. + case-insensitive match against the full user list. Inactive users are + resolved too. Returns a mapping of email (as passed) to user ID for the emails that were found. Emails with no matching user are omitted. @@ -128,7 +129,7 @@ async def resolve_ids(self, emails: list[str]) -> dict[str, str]: wanted = list(dict.fromkeys(email for email in emails if email)) if not wanted: return {} - users = await self.list_(names=wanted) + users = await self.list_(names=wanted, include_inactive=True) by_name = {user.name: user._id_or_error for user in users} resolved = {email: by_name[email] for email in wanted if email in by_name} @@ -136,7 +137,7 @@ async def resolve_ids(self, emails: list[str]) -> dict[str, str]: if missing: folded_to_email = {email.casefold(): email for email in missing} matches: dict[str, list[str]] = {} - for user in await self.list_(): + for user in await self.list_(include_inactive=True): email = folded_to_email.get(user.name.casefold()) if email is not None: matches.setdefault(email, []).append(user._id_or_error) From 5350ce02b2d1c4448e00df202ecf2cfedace1f3e Mon Sep 17 00:00:00 2001 From: Wei Qi Lu Date: Fri, 10 Jul 2026 17:09:16 -0700 Subject: [PATCH 21/21] polish docs, dedupe inputs, reject empty enum sets --- python/CHANGELOG.md | 2 +- .../resources/test_principal_attributes.py | 13 ++++++++ .../resources/test_resource_attributes.py | 31 +++++++++++++++++++ .../resources/access_control/_common.py | 5 +++ .../access_control/principal_attributes.py | 3 +- .../access_control/resource_attributes.py | 3 ++ .../resources/sync_stubs/__init__.pyi | 14 +++++++-- .../sift_types/principal_attribute.py | 5 +-- 8 files changed, 69 insertions(+), 7 deletions(-) diff --git a/python/CHANGELOG.md b/python/CHANGELOG.md index 7f58090d5..f1cf3bfed 100644 --- a/python/CHANGELOG.md +++ b/python/CHANGELOG.md @@ -91,7 +91,7 @@ licenses = key.get_or_create_enum_values(["LICENSE_A"]) key.assign_to(["user@example.com"], value=licenses) ``` -Keys, enum values, and assignments are managed through the nested `keys`, `enum_values`, and `assignments` APIs on each side (for example `client.access_control.resource_attributes.assignments.list_()`), each supporting the standard create, get, list, update, and archive operations. For `SET_OF_ENUM` keys, an assignment replaces the full value set on each target, and each enum value in the set is returned as its own assignment. +Keys, enum values, and assignments are managed through the nested `keys`, `enum_values`, and `assignments` APIs on each side (for example `client.access_control.resource_attributes.assignments.list_()`), each supporting the relevant create, get, list, update, and archive operations. For `SET_OF_ENUM` keys, an assignment replaces the full value set on each target, and each enum value in the set is returned as its own assignment. #### Users diff --git a/python/lib/sift_client/_tests/resources/test_principal_attributes.py b/python/lib/sift_client/_tests/resources/test_principal_attributes.py index 6ec5e9586..85a543d08 100644 --- a/python/lib/sift_client/_tests/resources/test_principal_attributes.py +++ b/python/lib/sift_client/_tests/resources/test_principal_attributes.py @@ -87,6 +87,19 @@ async def test_email_in_user_group_ref_raises(self): _key(), [PrincipalRef.user_group("group@x.com")], value=["e_a"] ) + @pytest.mark.asyncio + async def test_duplicate_principals_are_deduped(self): + api = _api() + api.client.async_.users.resolve_ids = AsyncMock(return_value={"alice@x.com": "u1"}) + api.assignments._low_level_client.batch_create_values = AsyncMock(return_value=[]) + + await api.assignments.create( + _key(), ["alice@x.com", PrincipalRef.user("u1")], value=["e_a"] + ) + + kwargs = api.assignments._low_level_client.batch_create_values.call_args.kwargs + assert kwargs["principal_ids"] == ["u1"] + @pytest.mark.asyncio async def test_bare_principal_id_raises_type_error(self): api = _api() diff --git a/python/lib/sift_client/_tests/resources/test_resource_attributes.py b/python/lib/sift_client/_tests/resources/test_resource_attributes.py index 091d6566c..ac9482b40 100644 --- a/python/lib/sift_client/_tests/resources/test_resource_attributes.py +++ b/python/lib/sift_client/_tests/resources/test_resource_attributes.py @@ -161,6 +161,37 @@ async def test_fetches_key_when_assigning_by_key_id(self): assert kwargs["key_id"] == "k1" assert kwargs["enum_value_ids"] == ["e_a"] + @pytest.mark.asyncio + async def test_set_of_enum_rejects_empty_value_list(self): + api = _api() + api.assignments._low_level_client.batch_create_resource_attributes = AsyncMock() + + with pytest.raises(ValueError, match="at least one enum value"): + await api.assignments.create( + _key(), [ResourceAttributeEntity.for_channel("ch1")], value=[] + ) + + api.assignments._low_level_client.batch_create_resource_attributes.assert_not_called() + + @pytest.mark.asyncio + async def test_duplicate_resources_are_deduped(self): + api = _api() + api.assignments._low_level_client.batch_create_resource_attributes = AsyncMock( + return_value=[] + ) + + await api.assignments.create( + _key(), + [ + ResourceAttributeEntity.for_channel("ch1"), + ResourceAttributeEntity.for_channel("ch1"), + ], + value=["e_a"], + ) + + kwargs = api.assignments._low_level_client.batch_create_resource_attributes.call_args.kwargs + assert len(kwargs["entities"]) == 1 + @pytest.mark.asyncio async def test_bare_string_resource_raises_type_error(self): api = _api() diff --git a/python/lib/sift_client/resources/access_control/_common.py b/python/lib/sift_client/resources/access_control/_common.py index 56e5c9fa2..93230a4c4 100644 --- a/python/lib/sift_client/resources/access_control/_common.py +++ b/python/lib/sift_client/resources/access_control/_common.py @@ -44,6 +44,11 @@ def attribute_value_kwargs(value_type: Enum, value: Any) -> dict[str, Any]: if value_type.name == "SET_OF_ENUM": if not isinstance(value, (list, tuple)): raise TypeError("SET_OF_ENUM keys require a list of enum values.") + if not value: + raise ValueError( + "SET_OF_ENUM keys require at least one enum value; archive the existing " + "assignments to clear a set." + ) return {"enum_value_ids": [id_of(v) for v in value]} if value_type.name == "ENUM": if isinstance(value, (list, tuple)): diff --git a/python/lib/sift_client/resources/access_control/principal_attributes.py b/python/lib/sift_client/resources/access_control/principal_attributes.py index cfd849b70..ee373fea1 100644 --- a/python/lib/sift_client/resources/access_control/principal_attributes.py +++ b/python/lib/sift_client/resources/access_control/principal_attributes.py @@ -530,7 +530,7 @@ async def create( Returns: The created assignments, one per enum value per principal for - ``SET_OF_ENUM`` keys. + ``SET_OF_ENUM`` keys. Order is not guaranteed to match the input order. """ resolved_key = await resolve_key( key, @@ -538,6 +538,7 @@ async def create( getter=lambda key_id: self._low_level_client.get_key(key_id), ) refs = await self._resolve_email_refs([_as_principal_ref(p) for p in principals]) + refs = list({(ref.principal_type, ref.principal_id): ref for ref in refs}.values()) create_kwargs = attribute_value_kwargs(resolved_key.value_type, value) grouped: dict[PrincipalType, list[str]] = {} diff --git a/python/lib/sift_client/resources/access_control/resource_attributes.py b/python/lib/sift_client/resources/access_control/resource_attributes.py index d12134953..24408359c 100644 --- a/python/lib/sift_client/resources/access_control/resource_attributes.py +++ b/python/lib/sift_client/resources/access_control/resource_attributes.py @@ -507,6 +507,9 @@ async def create( getter=lambda key_id: self._low_level_client.get_key(key_id), ) resolved = [_resolve_resource_object(resource) for resource in resources] + resolved = list( + {(entity.entity_type, entity.entity_id): entity for entity in resolved}.values() + ) create_kwargs = attribute_value_kwargs(resolved_key.value_type, value) created = await self._low_level_client.batch_create_resource_attributes( diff --git a/python/lib/sift_client/resources/sync_stubs/__init__.pyi b/python/lib/sift_client/resources/sync_stubs/__init__.pyi index 1ae13eb3f..b8f596225 100644 --- a/python/lib/sift_client/resources/sync_stubs/__init__.pyi +++ b/python/lib/sift_client/resources/sync_stubs/__init__.pyi @@ -1239,7 +1239,7 @@ class PrincipalAttributeAssignmentsAPI: Returns: The created assignments, one per enum value per principal for - ``SET_OF_ENUM`` keys. + ``SET_OF_ENUM`` keys. Order is not guaranteed to match the input order. """ ... @@ -3417,10 +3417,18 @@ class UsersAPI: def resolve_ids(self, emails: list[str]) -> dict[str, str]: """Resolve user login emails (their user names) to user IDs. - Returns a mapping of email to user ID for the emails that were found. Emails - with no matching user are omitted. + Matching is case-insensitive. Login names are stored and compared + case-sensitively, so emails that miss on exact casing fall back to a + case-insensitive match against the full user list. Inactive users are + resolved too. + + Returns a mapping of email (as passed) to user ID for the emails that were + found. Emails with no matching user are omitted. Args: emails: The login emails to resolve. + + Raises: + ValueError: If an email matches multiple users case-insensitively. """ ... diff --git a/python/lib/sift_client/sift_types/principal_attribute.py b/python/lib/sift_client/sift_types/principal_attribute.py index 5d09dd8b9..d0fdfc1f6 100644 --- a/python/lib/sift_client/sift_types/principal_attribute.py +++ b/python/lib/sift_client/sift_types/principal_attribute.py @@ -154,7 +154,7 @@ class PrincipalAttributeAssignment( boolean_value: bool | None number_value: int | None key: PrincipalAttributeKey | None - """Full key details. Always set in responses.""" + """Full key details. Only set when the server includes them; use ``key_id`` otherwise.""" enum_value: PrincipalAttributeEnumValue | None """Full enum value details. Only set for enum values; ``value`` falls back to ``enum_value_id``.""" created_date: datetime | None @@ -323,7 +323,8 @@ def assign_to( Args: principals: Principals to assign to. Pass ``PrincipalRef.user(...)`` / ``PrincipalRef.user_group(...)`` references, ``User`` objects, or user - email addresses (resolved to user IDs automatically). + email addresses (resolved to user IDs automatically). Bare IDs are + rejected because they do not say which kind of principal they refer to. value: The value to assign. For ``SET_OF_ENUM`` keys, a list of enum values (or their IDs); for ``ENUM`` keys, a single enum value; for ``BOOLEAN`` keys, a bool; for ``NUMBER`` keys, an int. For ``SET_OF_ENUM`` this