Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
8efd2e5
python(refactor): group access control APIs under client.access_control
wei-qlu Jun 29, 2026
0038f96
lint
wei-qlu Jun 29, 2026
4211b0f
nest attributes in resources
wei-qlu Jun 29, 2026
53b9168
clarify access control namespace
wei-qlu Jun 29, 2026
2867138
update access control api docs and resource handling
wei-qlu Jun 30, 2026
1e72296
support ID-based assignments
wei-qlu Jun 30, 2026
447e363
mypy
wei-qlu Jun 30, 2026
204939d
Merge remote-tracking branch 'origin/main' into eng-12537-group-the-a…
wei-qlu Jul 2, 2026
6cab6eb
align access-control types and internals, add typed key updates and c…
wei-qlu Jul 2, 2026
d007cf4
restructure attributes into nested keys/enum_values/assignments sub-r…
wei-qlu Jul 8, 2026
6676245
raise error on combined arg for assignments.list_
wei-qlu Jul 8, 2026
e6b6f6f
update docstrings to inckude full args/return
wei-qlu Jul 8, 2026
73e9b08
drop UNSPECIFIED from public enums
wei-qlu Jul 8, 2026
30acf85
drop bare resource IDs, require typed ref
wei-qlu Jul 10, 2026
7c27a7a
add PrincipalRef and require typed refs for attribute assignments
wei-qlu Jul 10, 2026
ab6cd58
document when assignment detail fields are populated
wei-qlu Jul 10, 2026
e537a65
document SET_OF_ENUM row-per-value shape
wei-qlu Jul 10, 2026
17cd36b
expose standard time/user/description filters on attribute list methods
wei-qlu Jul 10, 2026
6920dd6
document access_control namespace scope
wei-qlu Jul 10, 2026
5ff9f0f
resolve user emails case-insensitively with exact-match fast path
wei-qlu Jul 10, 2026
68ccd5e
resolve inactive users by email in resolve_ids
wei-qlu Jul 11, 2026
5350ce0
polish docs, dedupe inputs, reject empty enum sets
wei-qlu Jul 11, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 24 additions & 8 deletions python/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -42,4 +43,5 @@
"TestResultsLowLevelClient",
"UnitsLowLevelClient",
"UploadLowLevelClient",
"UsersLowLevelClient",
]
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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.
Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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:
Expand All @@ -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(
Expand All @@ -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,
Expand All @@ -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,
Expand All @@ -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

Expand All @@ -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={
Expand All @@ -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,
Expand All @@ -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

Expand All @@ -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={
Expand All @@ -510,22 +504,24 @@ 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,
principal_attribute_value_ids: list[str],
*,
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,
)
)
)
Loading
Loading