diff --git a/python/CHANGELOG.md b/python/CHANGELOG.md index ed70f3f66..f1cf3bfed 100644 --- a/python/CHANGELOG.md +++ b/python/CHANGELOG.md @@ -63,29 +63,45 @@ Breaking change: `client.reports.create_from_template` now takes `report_templat #### 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 `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. Resource assignments accept supported resource objects, or a typed `ResourceAttributeEntity` when you only have an ID: ```python -from sift_client.sift_types import ResourceAttributeKeyType +from sift_client.sift_types import ResourceAttributeEntity, ResourceAttributeValueType -key = client.resource_attributes.get_or_create_key("licenses", ResourceAttributeKeyType.SET_OF_ENUM) +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(channels, 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: +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 -key = client.principal_attributes.get_or_create_key("licenses", PrincipalAttributeValueType.SET_OF_ENUM) +key = client.access_control.principal_attributes.keys.get_or_create( + "licenses", + PrincipalAttributeValueType.SET_OF_ENUM, +) 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 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 + +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 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..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, + ) ] ) ) @@ -108,3 +111,72 @@ 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", + principal_type=pa.PRINCIPAL_ATTRIBUTE_PRINCIPAL_TYPE_USER, + ) + ] + ) + ) + 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..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 @@ -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: @@ -24,14 +27,16 @@ 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, ) ) ) 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] @@ -46,16 +51,22 @@ 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) - 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: @@ -66,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="", ), @@ -158,3 +175,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/conftest.py b/python/lib/sift_client/_tests/conftest.py index 5790a2f7a..125f846aa 100644 --- a/python/lib/sift_client/_tests/conftest.py +++ b/python/lib/sift_client/_tests/conftest.py @@ -69,8 +69,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/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 f194b58ec..85a543d08 100644 --- a/python/lib/sift_client/_tests/resources/test_principal_attributes.py +++ b/python/lib/sift_client/_tests/resources/test_principal_attributes.py @@ -1,24 +1,31 @@ """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, and exercise the nested +keys/enum_values/assignments sub-resources. They are not integration tests. """ +from datetime import datetime, timezone from unittest.mock import AsyncMock, MagicMock 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, + PrincipalAttributeKeyUpdate, + PrincipalRef, PrincipalType, ) +from sift_client.util import cel_utils as cel 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 @@ -32,51 +39,34 @@ 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: +class TestAssignmentsCreate: @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): + async def test_fetches_key_when_assigning_by_key_id(self): api = _api() - api.client.rest_client.get = MagicMock(return_value=_users_response([])) + api.assignments._low_level_client.get_key = AsyncMock(return_value=_key()) + api.assignments._low_level_client.batch_create_values = AsyncMock(return_value=[]) - with pytest.raises(ValueError, match="No user found"): - await api.resolve_user_id("ghost@x.com") + 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"] -class TestAssign: @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._low_level_client.batch_create_values = AsyncMock(return_value=[]) + 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.assign(_key(), ["alice@x.com", "raw_id"], value=["e_a"]) + await api.assignments.create( + _key(), ["alice@x.com", PrincipalRef.user("raw_id")], value=["e_a"] + ) - kwargs = api._low_level_client.batch_create_values.call_args.kwargs + 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 assert kwargs["principal_ids"] == ["u1", "raw_id"] assert kwargs["principal_type"] == PrincipalType.USER.value assert kwargs["enum_value_ids"] == ["e_a"] @@ -84,38 +74,164 @@ 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"]) + 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.assign( - _key(), ["group@x.com"], value=["e_a"], principal_type=PrincipalType.USER_GROUP + await api.assignments.create( + _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"] -class TestListAssignmentsRouting: + @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 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() + 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 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): + api = _api() + api.keys._low_level_client.update_key = AsyncMock(return_value=_key()) + + await api.keys.update("pk1", {"display_name": "new name"}) + + 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 777459a6d..ac9482b40 100644 --- a/python/lib/sift_client/_tests/resources/test_resource_attributes.py +++ b/python/lib/sift_client/_tests/resources/test_resource_attributes.py @@ -1,35 +1,41 @@ """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 datetime import datetime, timezone from unittest.mock import AsyncMock, MagicMock 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, ResourceAttributeKey, - ResourceAttributeKeyType, + ResourceAttributeKeyUpdate, + ResourceAttributeValueType, ) +from sift_client.util import cel_utils as cel 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 -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 ) ) @@ -42,62 +48,70 @@ def _enum(eid: str, name: str) -> ResourceAttributeEnumValue: ) -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", ResourceAttributeKeyType.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", ResourceAttributeKeyType.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", @@ -107,7 +121,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"], @@ -118,19 +132,209 @@ 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" + @pytest.mark.asyncio + async def test_fetches_key_when_assigning_by_key_id(self): + api = _api() + 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.assignments.create( + "k1", [ResourceAttributeEntity.for_channel("ch1")], value=["e_a"] + ) + + 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_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() + api.assignments._low_level_client.batch_create_resource_attributes = AsyncMock() + + 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() + + +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 proto = AssetProto(asset_id="a1", name="asset") 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): + api = _api() + api.keys._low_level_client.update_key = AsyncMock(return_value=_key()) + + await api.keys.update("k1", {"display_name": "new name"}) + + 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" + + @pytest.mark.asyncio + async def test_model_update_takes_id_from_key_object(self): + api = _api() + api.keys._low_level_client.update_key = AsyncMock(return_value=_key()) + + await api.keys.update(_key(), ResourceAttributeKeyUpdate(description="d")) + + 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/resources/test_users.py b/python/lib/sift_client/_tests/resources/test_users.py new file mode 100644 index 000000000..79ace65d0 --- /dev/null +++ b/python/lib/sift_client/_tests/resources/test_users.py @@ -0,0 +1,151 @@ +"""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_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_users = AsyncMock() + + assert await api.resolve_ids([]) == {} + 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_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_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_users = AsyncMock(return_value=[_user("u1", "alice@x.com")]) + + await api.resolve_ids(["alice@x.com"]) + + 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_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_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 + 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 7eecfc70f..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,14 +1,19 @@ """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 ( + PrincipalAttributeAssignment, + PrincipalAttributeEnumValue, PrincipalAttributeKey, - PrincipalAttributeValue, + PrincipalAttributeKeyUpdate, PrincipalAttributeValueType, + PrincipalRef, PrincipalType, ) +from sift_client.sift_types.user import User def _key_proto() -> pa.PrincipalAttributeKey: @@ -33,7 +38,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 +47,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 +61,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", @@ -66,13 +71,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 = ( - PrincipalAttributeValue._from_proto(archived_proto) + mock_client.access_control.principal_attributes.assignments.get.return_value = ( + PrincipalAttributeAssignment._from_proto(archived_proto) ) result = value.archive() - mock_client.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 @@ -85,12 +90,16 @@ 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" ), ) - 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. @@ -100,19 +109,106 @@ 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.principal_attributes.assign.return_value = [] + 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.principal_attributes.assign.assert_called_once_with( - key, ["u1"], value=["LIC_A"], principal_type=PrincipalType.USER + mock_client.access_control.principal_attributes.assignments.create.assert_called_once_with( + key, principals, value=["LIC_A"] ) 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 057282782..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 @@ -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): @@ -97,12 +98,16 @@ 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" ), ) - 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,18 +118,20 @@ 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.resource_attributes.get_assignment.return_value = ResourceAttribute._from_proto( - archived + mock_client.access_control.resource_attributes.assignments.get.return_value = ( + ResourceAttributeAssignment._from_proto(archived) ) result = attr.archive() - mock_client.resource_attributes.archive_assignments.assert_called_once_with([attr]) + mock_client.access_control.resource_attributes.assignments.archive.assert_called_once_with( + [attr] + ) assert result is attr assert attr.is_archived is True @@ -134,22 +141,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.resource_attributes.archive_key.return_value = archived + mock_client.access_control.resource_attributes.keys.archive.return_value = archived result = key.archive() - mock_client.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.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.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"] @@ -157,7 +166,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.keys.check_archive_impact.return_value = 7 assert key.check_archive_impact() == 7 @@ -176,11 +185,64 @@ 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.enum_values.archive.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.enum_values.archive.assert_called_once_with( 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 bc03e74aa..b7c68b7a2 100644 --- a/python/lib/sift_client/client.py +++ b/python/lib/sift_client/client.py @@ -39,7 +39,10 @@ TagsAPIAsync, TestResultsAPI, TestResultsAPIAsync, + UsersAPI, + UsersAPIAsync, ) +from sift_client.resources.access_control import AccessControlAPI, AccessControlAPIAsync from sift_client.transport import ( GrpcClient, GrpcConfig, @@ -120,11 +123,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 + """Access-control APIs for configuring who can access what in Sift.""" tags: TagsAPI """Instance of the Tags API for making synchronous requests.""" @@ -132,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.""" @@ -212,10 +215,13 @@ 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.users = UsersAPI(self) self.data_export = DataExportAPI(self) self.data_import = DataImportAPI(self) @@ -231,10 +237,13 @@ 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), + 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 7b158c281..efa97fba3 100644 --- a/python/lib/sift_client/resources/__init__.py +++ b/python/lib/sift_client/resources/__init__.py @@ -163,13 +163,24 @@ 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 ( + 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.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 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 @@ -180,14 +191,21 @@ async def main(): ChannelsAPI, JobsAPI, PingAPI, + PrincipalAttributeAssignmentsAPI, + PrincipalAttributeEnumValuesAPI, + PrincipalAttributeKeysAPI, PrincipalAttributesAPI, ReportsAPI, ReportTemplatesAPI, + ResourceAttributeAssignmentsAPI, + ResourceAttributeEnumValuesAPI, + ResourceAttributeKeysAPI, ResourceAttributesAPI, RulesAPI, RunsAPI, TagsAPI, TestResultsAPI, + UsersAPI, FileAttachmentsAPI, DataExportAPI, DataImportAPI, @@ -218,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", @@ -235,6 +265,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/__init__.py b/python/lib/sift_client/resources/access_control/__init__.py new file mode 100644 index 000000000..3eff1b576 --- /dev/null +++ b/python/lib/sift_client/resources/access_control/__init__.py @@ -0,0 +1,67 @@ +"""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. + +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 __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 APIs for configuring who can access what in Sift.""" + + resource_attributes: ResourceAttributesAPI + """Manage attributes on supported resources, such as assets, channels, and runs.""" + + principal_attributes: PrincipalAttributesAPI + """Manage attributes on principals such as users and user groups.""" + + def __init__( + self, + *, + resource_attributes: ResourceAttributesAPI, + principal_attributes: PrincipalAttributesAPI, + ): + """Initialize the namespace.""" + self.resource_attributes = resource_attributes + self.principal_attributes = principal_attributes + + +class AccessControlAPIAsync: + """Async access-control APIs for configuring who can access what in Sift.""" + + resource_attributes: ResourceAttributesAPIAsync + """Manage attributes on supported resources, such as assets, channels, and runs.""" + + principal_attributes: PrincipalAttributesAPIAsync + """Manage attributes on principals such as users and user groups.""" + + def __init__( + self, + *, + resource_attributes: ResourceAttributesAPIAsync, + principal_attributes: PrincipalAttributesAPIAsync, + ): + """Initialize the namespace.""" + self.resource_attributes = resource_attributes + self.principal_attributes = principal_attributes 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..93230a4c4 --- /dev/null +++ b/python/lib/sift_client/resources/access_control/_common.py @@ -0,0 +1,67 @@ +"""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.") + 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)): + 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 new file mode 100644 index 000000000..ee373fea1 --- /dev/null +++ b/python/lib/sift_client/resources/access_control/principal_attributes.py @@ -0,0 +1,715 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +from sift_client._internal.low_level_wrappers.principal_attributes import ( + PrincipalAttributesLowLevelClient, +) +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, + 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: + import re + from datetime import datetime + + 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. + + 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`. Pass + ``User`` objects, ``PrincipalRef`` references, or user email addresses; use + ``PrincipalRef.user_group(...)`` for user groups. + """ + + 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. + """ + super().__init__(sift_client) + self._low_level_client = PrincipalAttributesLowLevelClient( + grpc_client=self.client.grpc_client + ) + + async def get(self, *, key_id: str) -> PrincipalAttributeKey: + """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) + + 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, + 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, + limit: int | None = None, + page_size: int | None = None, + ) -> list[PrincipalAttributeKey]: + """List principal attribute keys with optional filtering. + + 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. + 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. + 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( + 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)) + 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) + + keys = await self._low_level_client.list_all_keys( + query_filter=cel.and_(*filter_parts) or None, + order_by=order_by, + include_archived=include_archived, + max_results=limit, + **({"page_size": page_size} if page_size is not None else {}), + ) + return self._apply_client_to_instances(keys) + + async def find(self, **kwargs) -> PrincipalAttributeKey | None: + """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") + return keys[0] if keys else None + + async def create( + self, + display_name: str, + value_type: PrincipalAttributeValueType, + *, + description: str = "", + ) -> PrincipalAttributeKey: + """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 + ) + return self._apply_client_to_instance(key) + + async 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. + + 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. + """ + 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(display_name, value_type, description=description) + + async def update( + self, + key: str | PrincipalAttributeKey, + update: PrincipalAttributeKeyUpdate | dict, + ) -> PrincipalAttributeKey: + """Update a key. + + 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) + 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(self, key: str | PrincipalAttributeKey) -> PrincipalAttributeKey: + """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. + + 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: + """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)) + + +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, + *, + description: str = "", + ) -> PrincipalAttributeEnumValue: + """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 + ) + return self._apply_client_to_instance(value) + + async def list_( + self, + key: str | PrincipalAttributeKey, + *, + name: str | None = None, + 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, + limit: int | None = None, + page_size: int | None = None, + ) -> list[PrincipalAttributeEnumValue]: + """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. + 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. + 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 + ) + 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( + key_id=key_id, + query_filter=cel.and_(*filter_parts) or None, + order_by=order_by, + include_archived=include_archived, + max_results=limit, + **({"page_size": page_size} if page_size is not None else {}), + ) + return self._apply_client_to_instances(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. + + 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) + 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(key_id, name) + by_name[name] = value + result.append(value) + return result + + async def archive( + self, + enum_value: str | PrincipalAttributeEnumValue, + *, + replacement: str | PrincipalAttributeEnumValue | None = None, + ) -> int: + """Archive an enum value, migrating existing assignments to a replacement. + + 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 "" + return await self._low_level_client.archive_enum_value( + enum_value_id, replacement_enum_value_id=replacement_id + ) + + async def unarchive( + self, enum_value: str | PrincipalAttributeEnumValue + ) -> PrincipalAttributeEnumValue: + """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) + return self._apply_client_to_instance(value) + + +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[PrincipalRef | User | str], + *, + value: PrincipalAttributeValueLike, + ) -> 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: 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. + + Returns: + The created assignments, one per enum value per principal for + ``SET_OF_ENUM`` keys. Order is not guaranteed to match the input order. + """ + resolved_key = await resolve_key( + key, + key_cls=PrincipalAttributeKey, + 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]] = {} + 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( + self, + *, + assignment_id: str, + principal_type: PrincipalType, + ) -> PrincipalAttributeAssignment: + """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 + ) + return self._apply_client_to_instance(value) + + async def list_( + self, + *, + 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, + limit: int | None = None, + page_size: int | None = None, + ) -> 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``, + 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. + 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. + limit: Maximum number of assignments to return. + page_size: Results to fetch per request. + + Returns: + The matching assignments. + + Raises: + ValueError: If ``principal_type`` conflicts with the type of ``principal``. + """ + filter_parts = [] + if principal is not None: + (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 + 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 + + if key is not None: + values = await self._low_level_client.list_all_key_values( + key_id=id_of(key), + principal_type=principal_type.value, + query_filter=query_filter, + order_by=order_by, + include_archived=include_archived, + max_results=limit, + **({"page_size": page_size} if page_size is not None else {}), + ) + else: + values = await self._low_level_client.list_all_values( + principal_type=principal_type.value, + query_filter=query_filter, + order_by=order_by, + include_archived=include_archived, + max_results=limit, + **({"page_size": page_size} if page_size is not None else {}), + ) + return self._apply_client_to_instances(values) + + async def archive( + self, + assignments: list[str | PrincipalAttributeAssignment], + *, + principal_type: PrincipalType, + ) -> None: + """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) + + async def unarchive( + self, + assignments: list[str | PrincipalAttributeAssignment], + *, + principal_type: PrincipalType, + ) -> None: + """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) + + async def _resolve_email_refs(self, refs: list[PrincipalRef]) -> list[PrincipalRef]: + """Resolve user emails (``@``) in principal references to user IDs.""" + emails = [ + 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[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 " + f"{ref.principal_id!r} for principal_type {ref.principal_type.name}. " + "Pass a principal ID instead." + ) + elif ref.principal_id not in email_to_id: + raise ValueError(f"No user found for email {ref.principal_id!r}") + else: + resolved.append(PrincipalRef.user(email_to_id[ref.principal_id])) + return resolved diff --git a/python/lib/sift_client/resources/access_control/resource_attributes.py b/python/lib/sift_client/resources/access_control/resource_attributes.py new file mode 100644 index 000000000..24408359c --- /dev/null +++ b/python/lib/sift_client/resources/access_control/resource_attributes.py @@ -0,0 +1,623 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING, Union + +from sift_client._internal.low_level_wrappers.resource_attributes import ( + ResourceAttributesLowLevelClient, +) +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 ( + ResourceAttributeAssignment, + ResourceAttributeEntity, + ResourceAttributeEnumValue, + ResourceAttributeKey, + ResourceAttributeKeyUpdate, + ResourceAttributeValueLike, + ResourceAttributeValueType, +) +from sift_client.sift_types.run import Run +from sift_client.util import cel_utils as cel + +if TYPE_CHECKING: + import re + from datetime import datetime + + from sift_client.client import SiftClient + +ResourceLike = Union[ResourceAttributeEntity, Asset, Channel, Run] + + +def _resolve_resource_object(resource: ResourceLike) -> ResourceAttributeEntity: + """Resolve a supported resource object to a ResourceAttributeEntity.""" + if isinstance(resource, ResourceAttributeEntity): + 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 resource of type {type(resource).__name__}. Pass a supported resource " + "object, such as an Asset, Channel, or Run, or a ResourceAttributeEntity built from " + "a resource ID." + ) + + +class ResourceAttributesAPIAsync(ResourceBase): + """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`. Pass + existing ``Asset``, ``Channel``, and ``Run`` objects directly, or build a + ``ResourceAttributeEntity`` from a resource ID. + """ + + 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. + + Args: + sift_client: The Sift client to use. + """ + super().__init__(sift_client) + self.keys = ResourceAttributeKeysAPIAsync(sift_client) + self.enum_values = ResourceAttributeEnumValuesAPIAsync(sift_client) + self.assignments = ResourceAttributeAssignmentsAPIAsync(sift_client) + + +class ResourceAttributeKeysAPIAsync(ResourceBase): + """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. + """ + super().__init__(sift_client) + self._low_level_client = ResourceAttributesLowLevelClient( + grpc_client=self.client.grpc_client + ) + + async def get(self, *, key_id: str) -> ResourceAttributeKey: + """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) + + 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, + 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, + limit: int | None = None, + page_size: int | None = None, + ) -> 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. + 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 `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 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) + + keys = await self._low_level_client.list_all_keys( + query_filter=cel.and_(*filter_parts) or None, + order_by=order_by, + include_archived=include_archived, + max_results=limit, + **({"page_size": page_size} if page_size is not None else {}), + ) + return self._apply_client_to_instances(keys) + + async def find(self, **kwargs) -> ResourceAttributeKey | None: + """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") + return keys[0] if keys else None + + async 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. + """ + key = await self._low_level_client.create_key( + display_name=display_name, value_type=value_type.value, description=description + ) + return self._apply_client_to_instance(key) + + async 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. + + 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. + """ + 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(display_name, value_type, description=description) + + async def update( + self, + key: str | ResourceAttributeKey, + update: ResourceAttributeKeyUpdate | dict, + ) -> ResourceAttributeKey: + """Update a key. + + 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) + 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(self, key: str | ResourceAttributeKey) -> ResourceAttributeKey: + """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. + + 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: + """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)) + + +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, + *, + description: str = "", + ) -> ResourceAttributeEnumValue: + """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 + ) + return self._apply_client_to_instance(value) + + async def list_( + self, + key: str | ResourceAttributeKey, + *, + name: str | None = None, + 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, + limit: int | None = None, + page_size: int | None = None, + ) -> 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. + 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 + ) + 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( + key_id=key_id, + query_filter=cel.and_(*filter_parts) or None, + order_by=order_by, + include_archived=include_archived, + max_results=limit, + **({"page_size": page_size} if page_size is not None else {}), + ) + return self._apply_client_to_instances(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. + + 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) + 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(key_id, name) + by_name[name] = value + result.append(value) + return result + + async def archive( + self, + enum_value: str | ResourceAttributeEnumValue, + *, + replacement: str | ResourceAttributeEnumValue | None = None, + ) -> int: + """Archive an enum value, migrating existing assignments to a replacement. + + 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 "" + return await self._low_level_client.archive_enum_value( + enum_value_id, replacement_enum_value_id=replacement_id + ) + + async def unarchive( + self, enum_value: str | ResourceAttributeEnumValue + ) -> ResourceAttributeEnumValue: + """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) + return self._apply_client_to_instance(value) + + +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], + *, + value: ResourceAttributeValueLike, + ) -> list[ResourceAttributeAssignment]: + """Assign a key's value to resources. + + Args: + key: The key or key ID to assign. Its ``value_type`` determines how ``value`` is interpreted. + 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. + + Returns: + The created assignments, one per enum value per resource for + ``SET_OF_ENUM`` keys. + """ + resolved_key = await resolve_key( + key, + key_cls=ResourceAttributeKey, + 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( + key_id=resolved_key._id_or_error, entities=resolved, **create_kwargs + ) + return self._apply_client_to_instances(created) + + async def get(self, *, assignment_id: str) -> ResourceAttributeAssignment: + """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) + + async def list_( + self, + *, + 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, + limit: int | None = None, + page_size: int | None = None, + ) -> 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 + 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. + 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 other filter arguments, which + the by-resource listing does not support. + """ + if resource 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 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( + entity=resolved, + include_archived=include_archived, + max_results=limit, + **({"page_size": page_size} if page_size is not None else {}), + ) + return self._apply_client_to_instances(attrs) + + 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) + + attrs = await self._low_level_client.list_all_resource_attributes( + query_filter=cel.and_(*filter_parts) or None, + order_by=order_by, + include_archived=include_archived, + max_results=limit, + **({"page_size": page_size} if page_size is not None else {}), + ) + return self._apply_client_to_instances(attrs) + + async def archive(self, assignments: list[str | ResourceAttributeAssignment]) -> None: + """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. + + 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/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/principal_attributes.py b/python/lib/sift_client/resources/principal_attributes.py deleted file mode 100644 index 06e24fedc..000000000 --- a/python/lib/sift_client/resources/principal_attributes.py +++ /dev/null @@ -1,529 +0,0 @@ -from __future__ import annotations - -from typing import TYPE_CHECKING, Any - -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.sift_types.principal_attribute import ( - PrincipalAttributeEnumValue, - PrincipalAttributeKey, - PrincipalAttributeValue, - PrincipalAttributeValueType, - PrincipalType, -) -from sift_client.util import cel_utils as cel - -if TYPE_CHECKING: - import re - - 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 (ABAC). - - 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. - """ - - def __init__(self, sift_client: SiftClient): - """Initialize the PrincipalAttributesAPI. - - Args: - sift_client: The Sift client to use. - """ - super().__init__(sift_client) - self._low_level_client = PrincipalAttributesLowLevelClient( - 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) - return self._apply_client_to_instance(key) - - async def list_keys( - self, - *, - name: str | None = None, - names: list[str] | None = None, - name_contains: str | None = None, - name_regex: str | re.Pattern | None = None, - value_type: PrincipalAttributeValueType | None = None, - include_archived: bool = False, - filter_query: str | None = None, - order_by: str | None = None, - limit: int | None = None, - page_size: int | None = None, - ) -> list[PrincipalAttributeKey]: - """List principal attribute keys with optional filtering. - - 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. - include_archived: If True, include archived keys. - filter_query: Explicit CEL query. - order_by: Field and direction to order by. - limit: Maximum number of keys to return. - 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 - ) - if value_type is not None: - filter_parts.append(cel.equals("value_type", value_type.value)) - if filter_query: - filter_parts.append(filter_query) - - keys = await self._low_level_client.list_all_keys( - query_filter=cel.and_(*filter_parts) or None, - order_by=order_by, - include_archived=include_archived, - max_results=limit, - **({"page_size": page_size} if page_size is not None else {}), - ) - return self._apply_client_to_instances(keys) - - async def find_key(self, **kwargs) -> PrincipalAttributeKey | None: - """Find a single key matching the query. Raises if more than one matches.""" - keys = await self.list_keys(**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( - self, - display_name: str, - value_type: PrincipalAttributeValueType, - *, - description: str = "", - ) -> PrincipalAttributeKey: - """Create a principal attribute key.""" - key = await self._low_level_client.create_key( - 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, - 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. - """ - existing = await self.list_keys(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) - - async def update_key( - self, - key: str | PrincipalAttributeKey, - *, - display_name: str | None = None, - description: str | None = None, - ) -> 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 - ) - 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 - 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 - 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 | PrincipalAttributeKey) -> int: - """Return the number of active assignments archiving this key would affect. - - 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) - - # ───────── Enum values ───────── - - async def create_enum_value( - self, - key: str | PrincipalAttributeKey, - display_name: str, - *, - description: str = "", - ) -> PrincipalAttributeEnumValue: - """Create a single enum value for a key.""" - key_id = key._id_or_error if isinstance(key, PrincipalAttributeKey) else key - value = await self._low_level_client.create_enum_value( - key_id=key_id, display_name=display_name, description=description - ) - return self._apply_client_to_instance(value) - - async def list_enum_values( - self, - key: str | PrincipalAttributeKey, - *, - name: str | None = None, - names: list[str] | None = None, - name_contains: str | None = None, - name_regex: str | re.Pattern | None = None, - include_archived: bool = False, - filter_query: str | None = None, - order_by: str | None = None, - limit: int | None = None, - 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 - 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) - values = await self._low_level_client.list_all_enum_values( - key_id=key_id, - query_filter=cel.and_(*filter_parts) or None, - order_by=order_by, - include_archived=include_archived, - max_results=limit, - **({"page_size": page_size} if page_size is not None else {}), - ) - return self._apply_client_to_instances(values) - - async 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``. - """ - key_id = key._id_or_error if isinstance(key, PrincipalAttributeKey) else 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] = [] - for name in names: - value = by_name.get(name) - if value is None: - value = await self.create_enum_value(key_id, name) - by_name[name] = value - result.append(value) - return result - - async 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. - """ - enum_value_id = _enum_value_id(enum_value) - replacement_id = _enum_value_id(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 - ) - - async def unarchive_enum_value( - self, enum_value: str | PrincipalAttributeEnumValue - ) -> PrincipalAttributeEnumValue: - """Unarchive an enum value.""" - enum_value_id = _enum_value_id(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) - - # ───────── Assignments (values) ───────── - - async def assign( - self, - key: PrincipalAttributeKey, - principals: list[str], - *, - value: Any, - principal_type: PrincipalType = PrincipalType.USER, - ) -> list[PrincipalAttributeValue]: - """Assign a value to principals for a key. - - 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. - 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``. - - Returns: - The created assignments. - """ - if not isinstance(key, PrincipalAttributeKey): - raise TypeError("assign requires a PrincipalAttributeKey (with a known value_type).") - resolved_ids = await self._resolve_principal_ids(principals, principal_type=principal_type) - create_kwargs = _principal_value_kwargs(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, - principal_ids=batch, - principal_type=principal_type.value, - **create_kwargs, - ) - created.extend(values) - return self._apply_client_to_instances(created) - - async def get_assignment( - self, - *, - assignment_id: str, - principal_type: PrincipalType = PrincipalType.USER, - ) -> PrincipalAttributeValue: - """Get a single assignment by ID.""" - value = await self._low_level_client.get_value( - assignment_id, principal_type=principal_type.value - ) - return self._apply_client_to_instance(value) - - async def list_assignments( - self, - *, - key: str | PrincipalAttributeKey | None = None, - principal: str | None = None, - principal_type: PrincipalType = PrincipalType.USER, - include_archived: bool = False, - filter_query: str | None = None, - order_by: str | None = None, - limit: int | None = None, - page_size: int | None = None, - ) -> list[PrincipalAttributeValue]: - """List principal attribute assignments. - - Args: - key: Filter to assignments of this key. - principal: Filter to assignments for this principal (user ID, or email for users). - 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. - order_by: Field and direction to order by. - limit: Maximum number of assignments to return. - page_size: Results to fetch per request. - """ - 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)) - if filter_query: - filter_parts.append(filter_query) - 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, - principal_type=principal_type.value, - query_filter=query_filter, - order_by=order_by, - include_archived=include_archived, - max_results=limit, - **({"page_size": page_size} if page_size is not None else {}), - ) - else: - values = await self._low_level_client.list_all_values( - principal_type=principal_type.value, - query_filter=query_filter, - order_by=order_by, - include_archived=include_archived, - max_results=limit, - **({"page_size": page_size} if page_size is not None else {}), - ) - return self._apply_client_to_instances(values) - - async def archive_assignments( - self, - assignments: list[str | PrincipalAttributeValue], - *, - 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) - - async def unarchive_assignments( - self, - assignments: list[str | PrincipalAttributeValue], - *, - 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 - ) - - # ───────── User resolution ───────── - - 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 - } - - 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.""" - emails = [ - p - 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 {} - 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: - 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." - ) - elif principal_type == PrincipalType.USER and "@" in principal: - raise ValueError(f"No user found for email {principal!r}") - 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/resource_attributes.py b/python/lib/sift_client/resources/resource_attributes.py deleted file mode 100644 index 360d089cd..000000000 --- a/python/lib/sift_client/resources/resource_attributes.py +++ /dev/null @@ -1,427 +0,0 @@ -from __future__ import annotations - -from typing import TYPE_CHECKING, Any, Union - -from sift_client._internal.low_level_wrappers.resource_attributes import ( - ResourceAttributesLowLevelClient, -) -from sift_client.resources._base import ResourceBase -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, - ResourceAttributeEntity, - ResourceAttributeEnumValue, - ResourceAttributeKey, - ResourceAttributeKeyType, -) -from sift_client.sift_types.run import Run -from sift_client.util import cel_utils as cel - -if TYPE_CHECKING: - import re - - 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. -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) - raise TypeError( - f"Cannot resolve entity of type {type(entity).__name__}. Pass a ResourceAttributeEntity, " - "Asset, Channel, or Run." - ) - - -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 (ABAC). - - 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. - """ - - def __init__(self, sift_client: SiftClient): - """Initialize the ResourceAttributesAPI. - - Args: - sift_client: The Sift client to use. - """ - super().__init__(sift_client) - self._low_level_client = ResourceAttributesLowLevelClient( - 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) - return self._apply_client_to_instance(key) - - async def list_keys( - self, - *, - name: str | None = None, - names: list[str] | None = None, - name_contains: str | None = None, - name_regex: str | re.Pattern | None = None, - key_type: ResourceAttributeKeyType | None = None, - include_archived: bool = False, - filter_query: str | None = None, - order_by: str | None = None, - limit: int | None = None, - page_size: int | None = None, - ) -> list[ResourceAttributeKey]: - """List resource attribute keys with optional filtering. - - 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. - key_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. - 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 `name`. - 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 filter_query: - filter_parts.append(filter_query) - - keys = await self._low_level_client.list_all_keys( - query_filter=cel.and_(*filter_parts) or None, - order_by=order_by, - include_archived=include_archived, - max_results=limit, - **({"page_size": page_size} if page_size is not None else {}), - ) - return self._apply_client_to_instances(keys) - - async def find_key(self, **kwargs) -> ResourceAttributeKey | None: - """Find a single key matching the query. Raises if more than one matches.""" - keys = await self.list_keys(**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( - self, - display_name: str, - key_type: ResourceAttributeKeyType, - *, - 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. - 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 - ) - return self._apply_client_to_instance(key) - - async def get_or_create_key( - self, - display_name: str, - key_type: ResourceAttributeKeyType, - *, - 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. - """ - existing = await self.list_keys(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, key_type, description=description) - - async def update_key( - self, - key: str | ResourceAttributeKey, - *, - display_name: str | None = None, - description: str | None = None, - ) -> 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 - ) - 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 - 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 - 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) - - # ───────── Enum values ───────── - - async def create_enum_value( - self, - key: str | ResourceAttributeKey, - display_name: str, - *, - description: str = "", - ) -> ResourceAttributeEnumValue: - """Create a single enum value for a key.""" - key_id = key._id_or_error if isinstance(key, ResourceAttributeKey) else key - value = await self._low_level_client.create_enum_value( - key_id=key_id, display_name=display_name, description=description - ) - return self._apply_client_to_instance(value) - - async def list_enum_values( - self, - key: str | ResourceAttributeKey, - *, - name: str | None = None, - names: list[str] | None = None, - name_contains: str | None = None, - name_regex: str | re.Pattern | None = None, - include_archived: bool = False, - filter_query: str | None = None, - order_by: str | None = None, - limit: int | None = None, - 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 - 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) - values = await self._low_level_client.list_all_enum_values( - key_id=key_id, - query_filter=cel.and_(*filter_parts) or None, - order_by=order_by, - include_archived=include_archived, - max_results=limit, - **({"page_size": page_size} if page_size is not None else {}), - ) - return self._apply_client_to_instances(values) - - async 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``. - """ - key_id = key._id_or_error if isinstance(key, ResourceAttributeKey) else 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] = [] - for name in names: - value = by_name.get(name) - if value is None: - value = await self.create_enum_value(key_id, name) - by_name[name] = value - result.append(value) - return result - - async 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. - """ - enum_value_id = _enum_value_id(enum_value) - replacement_id = _enum_value_id(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 - ) - - async def unarchive_enum_value( - self, enum_value: str | ResourceAttributeEnumValue - ) -> ResourceAttributeEnumValue: - """Unarchive an enum value.""" - enum_value_id = _enum_value_id(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) - - # ───────── Assignments ───────── - - async def assign( - self, - key: ResourceAttributeKey, - entities: list[ResourceAttributeEntity | Asset | Channel | Run], - *, - value: Any, - ) -> list[ResourceAttribute]: - """Assign a value to entities for a key. - - Args: - key: The key to assign. Its ``key_type`` determines how ``value`` is interpreted. - entities: Entities to assign to (ResourceAttributeEntity, Asset, Channel, or Run). - 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 - bool; for ``NUMBER``, an int. - - Returns: - The created assignments. - """ - if not isinstance(key, ResourceAttributeKey): - raise TypeError("assign requires a ResourceAttributeKey (with a known key_type).") - resolved = [_resolve_entity(e) for e in entities] - create_kwargs = _resource_value_kwargs(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 - ) - created.extend(attrs) - return self._apply_client_to_instances(created) - - async def get_assignment(self, *, assignment_id: str) -> ResourceAttribute: - """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( - self, - *, - key: str | ResourceAttributeKey | None = None, - entity: ResourceAttributeEntity | Asset | Channel | Run | None = None, - include_archived: bool = False, - filter_query: str | None = None, - order_by: str | None = None, - limit: int | None = None, - page_size: int | None = None, - ) -> list[ResourceAttribute]: - """List resource attribute assignments. - - Args: - key: Filter to assignments of this key. - entity: Filter to assignments on this entity. 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) - attrs = await self._low_level_client.list_all_resource_attributes_by_entity( - entity=resolved, - include_archived=include_archived, - max_results=limit, - **({"page_size": page_size} if page_size is not None else {}), - ) - return self._apply_client_to_instances(attrs) - - 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)) - if filter_query: - filter_parts.append(filter_query) - - attrs = await self._low_level_client.list_all_resource_attributes( - query_filter=cel.and_(*filter_parts) or None, - order_by=order_by, - include_archived=include_archived, - max_results=limit, - **({"page_size": page_size} if page_size is not None else {}), - ) - return self._apply_client_to_instances(attrs) - - async def archive_assignments(self, assignments: list[str | ResourceAttribute]) -> 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) - - async def unarchive_assignments(self, assignments: list[str | ResourceAttribute]) -> 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}.") diff --git a/python/lib/sift_client/resources/sync_stubs/__init__.py b/python/lib/sift_client/resources/sync_stubs/__init__.py index 4406bd289..b9544706d 100644 --- a/python/lib/sift_client/resources/sync_stubs/__init__.py +++ b/python/lib/sift_client/resources/sync_stubs/__init__.py @@ -12,14 +12,21 @@ FileAttachmentsAPIAsync, JobsAPIAsync, PingAPIAsync, + PrincipalAttributeAssignmentsAPIAsync, + PrincipalAttributeEnumValuesAPIAsync, + PrincipalAttributeKeysAPIAsync, PrincipalAttributesAPIAsync, ReportsAPIAsync, ReportTemplatesAPIAsync, + ResourceAttributeAssignmentsAPIAsync, + ResourceAttributeEnumValuesAPIAsync, + ResourceAttributeKeysAPIAsync, ResourceAttributesAPIAsync, RulesAPIAsync, RunsAPIAsync, TagsAPIAsync, TestResultsAPIAsync, + UsersAPIAsync, ) PingAPI = generate_sync_api(PingAPIAsync, "PingAPI") @@ -35,10 +42,47 @@ 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") DataExportAPI = generate_sync_api(DataExportAPIAsync, "DataExportAPI") DataImportAPI = generate_sync_api(DataImportAPIAsync, "DataImportAPI") @@ -51,12 +95,19 @@ "FileAttachmentsAPI", "JobsAPI", "PingAPI", + "PrincipalAttributeAssignmentsAPI", + "PrincipalAttributeEnumValuesAPI", + "PrincipalAttributeKeysAPI", "PrincipalAttributesAPI", "ReportTemplatesAPI", "ReportsAPI", + "ResourceAttributeAssignmentsAPI", + "ResourceAttributeEnumValuesAPI", + "ResourceAttributeKeysAPI", "ResourceAttributesAPI", "RulesAPI", "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 d977109be..b8f596225 100644 --- a/python/lib/sift_client/resources/sync_stubs/__init__.pyi +++ b/python/lib/sift_client/resources/sync_stubs/__init__.pyi @@ -41,10 +41,13 @@ if TYPE_CHECKING: JobType, ) from sift_client.sift_types.principal_attribute import ( + PrincipalAttributeAssignment, PrincipalAttributeEnumValue, PrincipalAttributeKey, - PrincipalAttributeValue, + PrincipalAttributeKeyUpdate, + PrincipalAttributeValueLike, PrincipalAttributeValueType, + PrincipalRef, PrincipalType, ) from sift_client.sift_types.report import Report, ReportUpdate @@ -54,11 +57,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 +82,7 @@ if TYPE_CHECKING: TestStepType, TestStepUpdate, ) + from sift_client.sift_types.user import User class AssetsAPI: """Sync counterpart to `AssetsAPIAsync`. @@ -1180,18 +1186,17 @@ class PingAPI: """ ... -class PrincipalAttributesAPI: - """Sync counterpart to `PrincipalAttributesAPIAsync`. +class PrincipalAttributeAssignmentsAPI: + """Sync counterpart to `PrincipalAttributeAssignmentsAPIAsync`. - High-level API for principal attributes (ABAC). + High-level API for principal attribute assignments. - 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. + 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. @@ -1199,135 +1204,182 @@ class PrincipalAttributesAPI: ... def _run(self, coro): ... - def archive_assignments( + def archive( self, - assignments: list[str | PrincipalAttributeValue], + 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 archive_enum_value( + def create( self, - enum_value: str | PrincipalAttributeEnumValue, + key: str | PrincipalAttributeKey, + principals: list[PrincipalRef | User | str], *, - replacement: str | PrincipalAttributeEnumValue | None = None, - ) -> int: - """Archive an enum value, migrating existing assignments to a replacement. + value: PrincipalAttributeValueLike, + ) -> 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: 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. - Returns the number of assignments migrated. + Returns: + The created assignments, one per enum value per principal for + ``SET_OF_ENUM`` keys. Order is not guaranteed to match the input order. """ ... - def archive_key(self, key: str | PrincipalAttributeKey) -> PrincipalAttributeKey: - """Archive a key. Cascades to its enum values and assignments.""" + def get( + self, *, assignment_id: str, principal_type: PrincipalType + ) -> PrincipalAttributeAssignment: + """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 assign( + def list_( self, - key: PrincipalAttributeKey, - principals: list[str], *, - value: Any, - principal_type: PrincipalType = PrincipalType.USER, - ) -> list[PrincipalAttributeValue]: - """Assign a value to principals for a key. + 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, + limit: int | None = None, + page_size: int | None = None, + ) -> list[PrincipalAttributeAssignment]: + """List principal attribute assignments. + + For ``SET_OF_ENUM`` keys, each enum value is returned as its own assignment. 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. - 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``. + key: Filter to assignments of this key. + 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. + 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. + limit: Maximum number of assignments to return. + page_size: Results to fetch per request. Returns: - The created assignments. + The matching assignments. + + Raises: + ValueError: If ``principal_type`` conflicts with the type of ``principal``. """ ... - def check_key_archive_impact(self, key: str | PrincipalAttributeKey) -> int: - """Return the number of active assignments archiving this key would affect. + def unarchive( + self, + assignments: list[str | PrincipalAttributeAssignment], + *, + principal_type: PrincipalType, + ) -> None: + """Batch unarchive assignments of the given principal type. - Counts both user and user-group assignments. + Args: + assignments: The assignments or assignment IDs to unarchive. + principal_type: The kind of principal the assignments apply to. """ ... - def create_enum_value( - self, key: str | PrincipalAttributeKey, display_name: str, *, description: str = "" - ) -> PrincipalAttributeEnumValue: - """Create a single enum value for a key.""" - ... +class PrincipalAttributeEnumValuesAPI: + """Sync counterpart to `PrincipalAttributeEnumValuesAPIAsync`. - def create_key( - self, display_name: str, value_type: PrincipalAttributeValueType, *, description: str = "" - ) -> PrincipalAttributeKey: - """Create a principal attribute key.""" - ... + High-level API for the enum values defined on principal attribute keys. - def find_key(self, **kwargs) -> PrincipalAttributeKey | None: - """Find a single key matching the query. Raises if more than one matches.""" - ... + Accessed as a nested resource via + ``client.access_control.principal_attributes.enum_values``. + """ - def get_assignment( - self, *, assignment_id: str, principal_type: PrincipalType = PrincipalType.USER - ) -> PrincipalAttributeValue: - """Get a single assignment by ID.""" - ... + def __init__(self, sift_client: SiftClient): + """Initialize the PrincipalAttributeEnumValuesAPI. - def get_key(self, *, key_id: str) -> PrincipalAttributeKey: - """Get a principal attribute key by ID.""" + Args: + sift_client: The Sift client to use. + """ ... - 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. + 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 values in the same order as ``names``. + 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 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. + def create( + self, key: str | PrincipalAttributeKey, display_name: str, *, description: str = "" + ) -> PrincipalAttributeEnumValue: + """Create a single enum value for a key. - Note: - Display names are not guaranteed unique. If multiple keys share the display - name, the first active match is returned. + 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 list_assignments( - self, - *, - key: str | PrincipalAttributeKey | None = None, - principal: str | None = None, - principal_type: PrincipalType = PrincipalType.USER, - include_archived: bool = False, - filter_query: str | None = None, - order_by: str | None = None, - limit: int | None = None, - page_size: int | None = None, - ) -> list[PrincipalAttributeValue]: - """List principal attribute assignments. + 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. Args: - key: Filter to assignments of this key. - principal: Filter to assignments for this principal (user ID, or email for users). - 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. - order_by: Field and direction to order by. - limit: Maximum number of assignments to return. - page_size: Results to fetch per request. + 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``. """ ... - def list_enum_values( + def list_( self, key: str | PrincipalAttributeKey, *, @@ -1335,16 +1387,159 @@ class PrincipalAttributesAPI: 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, 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. + 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. + 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. + + Args: + enum_value: The enum value or enum value ID to unarchive. + + Returns: + The unarchived 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. + + Args: + key: The key or key ID to archive. + + Returns: + The archived key. + """ + ... + + def check_archive_impact(self, key: str | PrincipalAttributeKey) -> int: + """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. + + 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 list_keys( + def find(self, **kwargs) -> PrincipalAttributeKey | None: + """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. + + Args: + key_id: The ID of the key. + + Returns: + The key. + """ + ... + + 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. + + 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. + """ + ... + + def list_( self, *, name: str | None = None, @@ -1352,6 +1547,13 @@ class PrincipalAttributesAPI: 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, @@ -1366,57 +1568,83 @@ class PrincipalAttributesAPI: 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. limit: Maximum number of keys to return. page_size: Results to fetch per request. + + Returns: + The matching keys. """ ... - def resolve_user_id(self, email: str) -> str: - """Resolve a user's email (its user name) to a user ID. + def unarchive(self, key: str | PrincipalAttributeKey) -> PrincipalAttributeKey: + """Unarchive a key. Does not restore its cascaded enum values or assignments. - Raises: - ValueError: If no user with that email is found. + Args: + key: The key or key ID to unarchive. + + Returns: + The unarchived key. """ ... - def resolve_user_ids(self, emails: list[str]) -> dict[str, str]: - """Resolve user emails (their user names) to user IDs. + def update( + self, key: str | PrincipalAttributeKey, update: PrincipalAttributeKeyUpdate | dict + ) -> PrincipalAttributeKey: + """Update a key. + + Args: + key: The key or key ID to update. + update: Updates to apply to the key. - Returns a mapping of email to user ID for the emails that were found. Emails with - no matching user are omitted. + Returns: + The updated key. """ ... - def unarchive_assignments( - self, - assignments: list[str | PrincipalAttributeValue], - *, - principal_type: PrincipalType = PrincipalType.USER, - ) -> None: - """Batch unarchive assignments.""" - ... +class PrincipalAttributesAPI: + """Sync counterpart to `PrincipalAttributesAPIAsync`. - def unarchive_enum_value( - self, enum_value: str | PrincipalAttributeEnumValue - ) -> PrincipalAttributeEnumValue: - """Unarchive an enum value.""" - ... + 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`. Pass + ``User`` objects, ``PrincipalRef`` references, or user email addresses; use + ``PrincipalRef.user_group(...)`` for user groups. + """ - def unarchive_key(self, key: str | PrincipalAttributeKey) -> PrincipalAttributeKey: - """Unarchive a key. Does not restore its cascaded enum values or assignments.""" + def __init__(self, sift_client: SiftClient): + """Initialize the PrincipalAttributesAPI. + + Args: + sift_client: The Sift client to use. + """ ... - def update_key( - self, - key: str | PrincipalAttributeKey, - *, - display_name: str | None = None, - description: str | None = None, - ) -> PrincipalAttributeKey: - """Update a key's display name or description.""" + 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: @@ -1746,81 +1974,305 @@ class ReportsAPI: modified_before: Filter reports modified before this datetime. Returns: - A list of Reports that matches the filter. + A list of Reports that matches the filter. + """ + ... + + def rerun(self, *, report: str | Report) -> Job: + """Rerun a report. + + Args: + report: The Report or report ID to rerun. + + Returns: + The Job for the new pending report. + """ + ... + + def unarchive(self, *, report: str | Report) -> Report: + """Unarchive a report.""" + ... + + def update(self, report: str | Report, update: ReportUpdate | dict) -> Report: + """Update a report. + + Args: + report: The Report or report ID to update. + update: The updates to apply. + """ + ... + + def wait_until_complete( + self, + *, + report: Report | str | None = None, + job: Job | str | None = None, + polling_interval_secs: int = 5, + timeout_secs: int | None = None, + ) -> Report: + """Wait until the report is complete or the timeout is reached. + + Polls the report job status at the given interval until the job is FINISHED, + FAILED, or CANCELLED, returning the completed Report. + + Either a report or job must be provided. The job must be a rule evaluation job. + + Args: + report: The Report or report ID to wait for. + job: The pending rule evaluation Job or job ID to wait for. + polling_interval_secs: Seconds between status polls. Defaults to 5s. + timeout_secs: Maximum seconds to wait. If None, polls indefinitely. + Defaults to None (indefinite). + + Returns: + The Report in the completed state. + + Raises: + ValueError: If both or neither report and job are provided, or if + job is not a rule evaluation job. + """ + ... + @property + def templates(self) -> ReportTemplatesAPI: + """Nested ReportTemplatesAPI for making synchronous requests.""" + ... + +class ResourceAttributeAssignmentsAPI: + """Sync counterpart to `ResourceAttributeAssignmentsAPIAsync`. + + 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. + """ + ... + + def _run(self, coro): ... + def archive(self, assignments: list[str | ResourceAttributeAssignment]) -> None: + """Batch archive assignments. + + Args: + assignments: The assignments or assignment IDs to archive. + """ + ... + + def create( + self, + key: str | ResourceAttributeKey, + resources: list[ResourceAttributeEntity | Asset | Channel | Run], + *, + value: ResourceAttributeValueLike, + ) -> list[ResourceAttributeAssignment]: + """Assign a key's value to resources. + + Args: + key: The key or key ID to assign. Its ``value_type`` determines how ``value`` is interpreted. + 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. + + Returns: + The created assignments, one per enum value per resource for + ``SET_OF_ENUM`` keys. + """ + ... + + def get(self, *, assignment_id: str) -> ResourceAttributeAssignment: + """Get a single assignment by ID. + + Args: + assignment_id: The ID of the assignment. + + Returns: + The assignment. + """ + ... + + def list_( + self, + *, + 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, + limit: int | None = None, + page_size: int | None = None, + ) -> 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 + 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. + 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 other filter arguments, which + the by-resource listing does not support. + """ + ... + + def unarchive(self, assignments: list[str | ResourceAttributeAssignment]) -> None: + """Batch unarchive assignments. + + Args: + assignments: The assignments or assignment IDs to unarchive. + """ + ... + +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. + + 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 rerun(self, *, report: str | Report) -> Job: - """Rerun a report. + def create( + self, key: str | ResourceAttributeKey, display_name: str, *, description: str = "" + ) -> ResourceAttributeEnumValue: + """Create a single enum value for a key. Args: - report: The Report or report ID to rerun. + 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 Job for the new pending report. + The created enum value. """ ... - def unarchive(self, *, report: str | Report) -> Report: - """Unarchive a report.""" - ... - - def update(self, report: str | Report, update: ReportUpdate | dict) -> Report: - """Update a report. + 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. Args: - report: The Report or report ID to update. - update: The updates to apply. + 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``. """ ... - def wait_until_complete( + def list_( self, + key: str | ResourceAttributeKey, *, - report: Report | str | None = None, - job: Job | str | None = None, - polling_interval_secs: int = 5, - timeout_secs: int | None = None, - ) -> Report: - """Wait until the report is complete or the timeout is reached. - - Polls the report job status at the given interval until the job is FINISHED, - FAILED, or CANCELLED, returning the completed Report. + name: str | None = None, + 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, + limit: int | None = None, + page_size: int | None = None, + ) -> list[ResourceAttributeEnumValue]: + """List the enum values defined for a key. - Either a report or job must be provided. The job must be a rule evaluation job. + The service does not yet support filtering enum values by description, + modified date, or user. Args: - report: The Report or report ID to wait for. - job: The pending rule evaluation Job or job ID to wait for. - polling_interval_secs: Seconds between status polls. Defaults to 5s. - timeout_secs: Maximum seconds to wait. If None, polls indefinitely. - Defaults to None (indefinite). + 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. + limit: Maximum number of enum values to return. + page_size: Results to fetch per request. Returns: - The Report in the completed state. - - Raises: - ValueError: If both or neither report and job are provided, or if - job is not a rule evaluation job. + The matching enum values. """ ... - @property - def templates(self) -> ReportTemplatesAPI: - """Nested ReportTemplatesAPI for making synchronous requests.""" + + def unarchive(self, enum_value: str | ResourceAttributeEnumValue) -> ResourceAttributeEnumValue: + """Unarchive an enum value. + + Args: + enum_value: The enum value or enum value ID to unarchive. + + Returns: + The unarchived enum value. + """ ... -class ResourceAttributesAPI: - """Sync counterpart to `ResourceAttributesAPIAsync`. +class ResourceAttributeKeysAPI: + """Sync counterpart to `ResourceAttributeKeysAPIAsync`. - High-level API for resource attributes (ABAC). + High-level API for resource attribute keys. - 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. + Accessed as a nested resource via ``client.access_control.resource_attributes.keys``. """ def __init__(self, sift_client: SiftClient): - """Initialize the ResourceAttributesAPI. + """Initialize the ResourceAttributeKeysAPI. Args: sift_client: The Sift client to use. @@ -1828,65 +2280,36 @@ class ResourceAttributesAPI: ... def _run(self, coro): ... - def archive_assignments(self, assignments: list[str | ResourceAttribute]) -> None: - """Batch archive assignments.""" - ... + def archive(self, key: str | ResourceAttributeKey) -> ResourceAttributeKey: + """Archive a key. Cascades to its enum values and 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. + Args: + key: The key or key ID to archive. - Returns the number of assignments migrated. + Returns: + The archived key. """ ... - def archive_key(self, key: str | ResourceAttributeKey) -> ResourceAttributeKey: - """Archive a key. Cascades to its enum values and assignments.""" - ... - - def assign( - self, - key: ResourceAttributeKey, - entities: list[ResourceAttributeEntity | Asset | Channel | Run], - *, - value: Any, - ) -> list[ResourceAttribute]: - """Assign a value to entities for a key. + def check_archive_impact(self, key: str | ResourceAttributeKey) -> int: + """Check how many assignments archiving a key would affect. Args: - key: The key to assign. Its ``key_type`` determines how ``value`` is interpreted. - entities: Entities to assign to (ResourceAttributeEntity, Asset, Channel, or Run). - 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 - bool; for ``NUMBER``, an int. + key: The key or key ID to check. Returns: - The created assignments. + The number of active assignments archiving this key would affect. """ ... - 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, key_type: ResourceAttributeKeyType, *, description: str = "" + 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. - key_type: The value type of the key. + value_type: The value type of the key. description: Optional description. Returns: @@ -1894,87 +2317,61 @@ class ResourceAttributesAPI: """ ... - def find_key(self, **kwargs) -> ResourceAttributeKey | None: - """Find a single key matching the query. Raises if more than one matches.""" - ... + def find(self, **kwargs) -> ResourceAttributeKey | None: + """Find a single key matching the query. Takes the same arguments as `list_`. - def get_assignment(self, *, assignment_id: str) -> ResourceAttribute: - """Get a single assignment by ID.""" - ... + Args: + **kwargs: Keyword arguments to pass to `list_`. + + Returns: + The key found, or None if no key matches. - def get_key(self, *, key_id: str) -> ResourceAttributeKey: - """Get a resource attribute key by ID.""" + Raises: + ValueError: If more than one key matches. + """ ... - 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. + def get(self, *, key_id: str) -> ResourceAttributeKey: + """Get a resource attribute key by ID. - Returns the values in the same order as ``names``. + Args: + key_id: The ID of the key. + + Returns: + The key. """ ... - def get_or_create_key( - self, display_name: str, key_type: ResourceAttributeKeyType, *, description: str = "" + 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. + 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. """ ... - def list_assignments( - self, - *, - key: str | ResourceAttributeKey | None = None, - entity: ResourceAttributeEntity | Asset | Channel | Run | None = None, - include_archived: bool = False, - filter_query: str | None = None, - order_by: str | None = None, - limit: int | None = None, - page_size: int | None = None, - ) -> list[ResourceAttribute]: - """List resource attribute assignments. - - Args: - key: Filter to assignments of this key. - entity: Filter to assignments on this entity. 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. - """ - ... - - def list_enum_values( - self, - key: str | ResourceAttributeKey, - *, - name: str | None = None, - names: list[str] | None = None, - name_contains: str | None = None, - name_regex: str | re.Pattern | None = None, - include_archived: bool = False, - filter_query: str | None = None, - order_by: str | None = None, - limit: int | None = None, - page_size: int | None = None, - ) -> list[ResourceAttributeEnumValue]: - """List the enum values defined for a key.""" - ... - - def list_keys( + def list_( self, *, name: str | None = None, 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, + 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, @@ -1983,12 +2380,17 @@ class ResourceAttributesAPI: ) -> 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. - key_type: Filter to keys of this value type. + 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. @@ -2000,28 +2402,65 @@ class ResourceAttributesAPI: """ ... - def unarchive_assignments(self, assignments: list[str | ResourceAttribute]) -> None: - """Batch unarchive assignments.""" + def unarchive(self, key: str | ResourceAttributeKey) -> ResourceAttributeKey: + """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 unarchive_enum_value( - self, enum_value: str | ResourceAttributeEnumValue - ) -> ResourceAttributeEnumValue: - """Unarchive an enum value.""" + def update( + self, key: str | ResourceAttributeKey, update: ResourceAttributeKeyUpdate | dict + ) -> ResourceAttributeKey: + """Update a key. + + Args: + key: The key or key ID to update. + update: Updates to apply to the key. + + Returns: + The updated key. + """ ... - def unarchive_key(self, key: str | ResourceAttributeKey) -> ResourceAttributeKey: - """Unarchive a key. Does not restore its cascaded enum values or assignments.""" +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`. Pass + existing ``Asset``, ``Channel``, and ``Run`` objects directly, or build a + ``ResourceAttributeEntity`` from a resource ID. + """ + + def __init__(self, sift_client: SiftClient): + """Initialize the ResourceAttributesAPI. + + Args: + sift_client: The Sift client to use. + """ ... - def update_key( - self, - key: str | ResourceAttributeKey, - *, - display_name: str | None = None, - description: str | None = None, - ) -> ResourceAttributeKey: - """Update a key's display name or description.""" + 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: @@ -2905,3 +3344,91 @@ 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. + + 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/resources/users.py b/python/lib/sift_client/resources/users.py new file mode 100644 index 000000000..8f0087940 --- /dev/null +++ b/python/lib/sift_client/resources/users.py @@ -0,0 +1,149 @@ +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. + + 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. + """ + wanted = list(dict.fromkeys(email for email in emails if email)) + if not wanted: + return {} + 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} + + 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_(include_inactive=True): + 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} diff --git a/python/lib/sift_client/sift_types/__init__.py b/python/lib/sift_client/sift_types/__init__.py index f8df88d61..5cb05a2ae 100644 --- a/python/lib/sift_client/sift_types/__init__.py +++ b/python/lib/sift_client/sift_types/__init__.py @@ -165,10 +165,13 @@ RuleEvaluationStatusDetails, ) from sift_client.sift_types.principal_attribute import ( + PrincipalAttributeAssignment, PrincipalAttributeEnumValue, PrincipalAttributeKey, - PrincipalAttributeValue, + PrincipalAttributeKeyUpdate, + PrincipalAttributeValueLike, PrincipalAttributeValueType, + PrincipalRef, PrincipalType, ) from sift_client.sift_types.report import Report, ReportRuleStatus, ReportRuleSummary, ReportUpdate @@ -179,12 +182,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 +215,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,10 +257,13 @@ "JobStatus", "JobStatusDetails", "JobType", + "PrincipalAttributeAssignment", "PrincipalAttributeEnumValue", "PrincipalAttributeKey", - "PrincipalAttributeValue", + "PrincipalAttributeKeyUpdate", + "PrincipalAttributeValueLike", "PrincipalAttributeValueType", + "PrincipalRef", "PrincipalType", "Report", "ReportRuleStatus", @@ -264,12 +273,14 @@ "ReportTemplateRule", "ReportTemplateUpdate", "ReportUpdate", - "ResourceAttribute", + "ResourceAttributeAssignment", "ResourceAttributeEntity", "ResourceAttributeEntityType", "ResourceAttributeEnumValue", "ResourceAttributeKey", - "ResourceAttributeKeyType", + "ResourceAttributeKeyUpdate", + "ResourceAttributeValueLike", + "ResourceAttributeValueType", "Rule", "RuleAction", "RuleActionType", @@ -295,4 +306,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 d555fd7df..d0fdfc1f6 100644 --- a/python/lib/sift_client/sift_types/principal_attribute.py +++ b/python/lib/sift_client/sift_types/principal_attribute.py @@ -1,11 +1,12 @@ -"""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. -- ``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. @@ -15,11 +16,13 @@ from datetime import datetime, timezone from enum import Enum -from typing import TYPE_CHECKING +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 +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 @@ -28,7 +31,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 @@ -38,11 +40,29 @@ 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 +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"] ): @@ -95,11 +115,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.principal_attributes.archive_enum_value(self, replacement=replacement) + return self.client.access_control.principal_attributes.enum_values.archive( + 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.enum_values.unarchive(self) self._update(updated) return self @@ -107,8 +129,22 @@ def __str__(self) -> str: return self.display_name -class PrincipalAttributeValue(BaseType[pa_pb.PrincipalAttributeValue, "PrincipalAttributeValue"]): - """A single assignment of a principal attribute value to a principal.""" +# 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. + + 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 @@ -118,7 +154,9 @@ class PrincipalAttributeValue(BaseType[pa_pb.PrincipalAttributeValue, "Principal boolean_value: bool | None number_value: int | None key: PrincipalAttributeKey | None + """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 created_by_user_id: str archived_date: datetime | None @@ -127,7 +165,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, @@ -176,25 +214,38 @@ 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.principal_attributes.archive_assignments( + self.client.access_control.principal_attributes.assignments.archive( [self], principal_type=self.principal_type ) self._update( - self.client.principal_attributes.get_assignment( + self.client.access_control.principal_attributes.assignments.get( assignment_id=self._id_or_error, principal_type=self.principal_type ) ) return self - def unarchive(self) -> PrincipalAttributeValue: + def unarchive(self) -> PrincipalAttributeAssignment: """Unarchive this assignment.""" - self.client.principal_attributes.unarchive_assignments( + self.client.access_control.principal_attributes.assignments.unarchive( [self], principal_type=self.principal_type ) self._update( - self.client.principal_attributes.get_assignment( + self.client.access_control.principal_attributes.assignments.get( assignment_id=self._id_or_error, principal_type=self.principal_type ) ) @@ -243,76 +294,97 @@ 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.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.principal_attributes.get_or_create_enum_values(self, names) + return self.client.access_control.principal_attributes.enum_values.get_or_create( + 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.enum_values.list_( self, include_archived=include_archived ) def assign_to( - self, principals, *, value, principal_type: PrincipalType = PrincipalType.USER - ) -> list[PrincipalAttributeValue]: + self, + principals: list[PrincipalRef | User | str], + *, + value: PrincipalAttributeValueLike, + ) -> list[PrincipalAttributeAssignment]: """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: 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: 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``. Returns: - The created assignments. + The created assignments, one per enum value for ``SET_OF_ENUM`` keys. """ - return self.client.principal_attributes.assign( - self, principals, value=value, principal_type=principal_type + return self.client.access_control.principal_attributes.assignments.create( + self, principals, value=value ) 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.principal_attributes.list_assignments( + return self.client.access_control.principal_attributes.assignments.list_( 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.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.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.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.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.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 + + +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 26775aa83..926a58ea3 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. +- ``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,21 +15,23 @@ from datetime import datetime, timezone from enum import Enum -from typing import TYPE_CHECKING +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 + 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): +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 @@ -37,33 +39,32 @@ 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 CHANNEL = ra_pb.RESOURCE_ATTRIBUTE_ENTITY_TYPE_CHANNEL RUN = ra_pb.RESOURCE_ATTRIBUTE_ENTITY_TYPE_RUN 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 @@ -132,11 +133,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.resource_attributes.archive_enum_value(self, replacement=replacement) + return self.client.access_control.resource_attributes.enum_values.archive( + 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.enum_values.unarchive(self) self._update(updated) return self @@ -144,17 +147,32 @@ def __str__(self) -> str: return self.display_name -class ResourceAttribute(BaseType[ra_pb.ResourceAttribute, "ResourceAttribute"]): - """A single assignment of a resource attribute value to an entity.""" +# 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. + + 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 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 @@ -163,7 +181,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, @@ -215,19 +233,36 @@ 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.resource_attributes.archive_assignments([self]) + self.client.access_control.resource_attributes.assignments.archive([self]) self._update( - self.client.resource_attributes.get_assignment(assignment_id=self._id_or_error) + self.client.access_control.resource_attributes.assignments.get( + assignment_id=self._id_or_error + ) ) return self - def unarchive(self) -> ResourceAttribute: + def unarchive(self) -> ResourceAttributeAssignment: """Unarchive this assignment.""" - self.client.resource_attributes.unarchive_assignments([self]) + self.client.access_control.resource_attributes.assignments.unarchive([self]) self._update( - self.client.resource_attributes.get_assignment(assignment_id=self._id_or_error) + self.client.access_control.resource_attributes.assignments.get( + assignment_id=self._id_or_error + ) ) return self @@ -238,7 +273,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 @@ -256,7 +291,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), @@ -274,69 +309,94 @@ 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.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.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.resource_attributes.list_enum_values( + return self.client.access_control.resource_attributes.enum_values.list_( 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: ResourceAttributeValueLike, + ) -> list[ResourceAttributeAssignment]: + """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. 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 - replaces the full set on each entity. + 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.resource_attributes.assign(self, entities, value=value) + return self.client.access_control.resource_attributes.assignments.create( + 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.resource_attributes.list_assignments( + return self.client.access_control.resource_attributes.assignments.list_( 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.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.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.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.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.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 + + +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 599064e83..80da28352 100644 --- a/python/lib/sift_client/util/util.py +++ b/python/lib/sift_client/util/util.py @@ -13,14 +13,14 @@ IngestionAPIAsync, JobsAPIAsync, PingAPIAsync, - PrincipalAttributesAPIAsync, ReportsAPIAsync, - ResourceAttributesAPIAsync, RulesAPIAsync, RunsAPIAsync, TagsAPIAsync, TestResultsAPIAsync, + UsersAPIAsync, ) + from sift_client.resources.access_control import AccessControlAPIAsync class AsyncAPIs(NamedTuple): @@ -56,11 +56,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 + """Async access-control APIs for configuring who can access what in Sift.""" tags: TagsAPIAsync """Instance of the Tags API for making asynchronous requests.""" @@ -68,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."""