Skip to content

Commit 3c0148e

Browse files
feat(iam): add support for attributes in groups create, groups list (#2231)
Co-authored-by: anders-albert <anders0albert@gmail.com>
1 parent de2f810 commit 3c0148e

2 files changed

Lines changed: 126 additions & 3 deletions

File tree

cognite/client/data_classes/iam.py

Lines changed: 62 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,13 @@
22

33
from abc import ABC
44
from collections.abc import Iterable
5+
from dataclasses import dataclass, field
56
from typing import TYPE_CHECKING, Any, Literal, TypeAlias, cast
67

78
from typing_extensions import Self
89

910
from cognite.client.data_classes._base import (
11+
CogniteObject,
1012
CogniteResource,
1113
CogniteResourceList,
1214
CogniteResponse,
@@ -24,17 +26,51 @@
2426

2527
from cognite.client import CogniteClient
2628

27-
2829
ALL_USER_ACCOUNTS = "allUserAccounts"
2930

3031

32+
@dataclass
33+
class GroupAttributesToken(CogniteObject):
34+
"""List of applications (represented by their application ID) this group is valid for"""
35+
36+
app_ids: list[str] = field(default_factory=list)
37+
38+
39+
@dataclass
40+
class GroupAttributes(CogniteObject):
41+
"""Attributes derived from access token"""
42+
43+
token: GroupAttributesToken | None = None
44+
_unknown_properties: dict[str, Any] = field(default_factory=dict, init=False, repr=False)
45+
46+
def dump(self, camel_case: bool = True) -> dict[str, Any]:
47+
"""Dumps the attributes to a dictionary"""
48+
dumped = super().dump(camel_case=camel_case)
49+
if self.token is not None:
50+
dumped["token"] = self.token.dump(camel_case=camel_case)
51+
if self._unknown_properties:
52+
dumped.update(self._unknown_properties)
53+
return dumped
54+
55+
@classmethod
56+
def _load(cls, resource: dict[str, Any], cognite_client: CogniteClient | None = None) -> Self:
57+
token: GroupAttributesToken | None = None
58+
if "token" in resource:
59+
token = GroupAttributesToken._load(resource["token"], cognite_client=cognite_client)
60+
instance = cls(token=token)
61+
existing = {"token"}
62+
instance._unknown_properties = {key: value for key, value in resource.items() if key not in existing}
63+
return instance
64+
65+
3166
class GroupCore(WriteableCogniteResource["GroupWrite"], ABC):
3267
"""No description.
3368
3469
Args:
3570
name (str): Name of the group.
3671
source_id (str | None): ID of the group in the source. If this is the same ID as a group in the IdP, a service account in that group will implicitly be a part of this group as well. Can not be used together with 'members'.
3772
capabilities (list[Capability] | None): List of capabilities (acls) this group should grant its users.
73+
attributes (GroupAttributes | None): Attributes of the group, this scopes down access based on the attributes specified.
3874
metadata (dict[str, str] | None): Custom, immutable application specific metadata. String key -> String value. Limits: Key are at most 32 bytes. Values are at most 512 bytes. Up to 16 key-value pairs. Total size is at most 4096.
3975
members (Literal['allUserAccounts'] | list[str] | None): Specifies which users are members of the group. Can not be used together with 'source_id'.
4076
"""
@@ -44,6 +80,7 @@ def __init__(
4480
name: str,
4581
source_id: str | None = None,
4682
capabilities: list[Capability] | None = None,
83+
attributes: GroupAttributes | None = None,
4784
metadata: dict[str, str] | None = None,
4885
members: Literal["allUserAccounts"] | list[str] | None = None,
4986
) -> None:
@@ -52,6 +89,7 @@ def __init__(
5289
self.capabilities = capabilities
5390
if isinstance(self.capabilities, Capability):
5491
self.capabilities = [capabilities]
92+
self.attributes = attributes
5593
self.metadata = metadata
5694
self.members = members
5795

@@ -62,6 +100,11 @@ def _load(cls, resource: dict, cognite_client: CogniteClient | None = None, allo
62100
source_id=resource.get("sourceId"),
63101
capabilities=[Capability.load(c, allow_unknown=allow_unknown) for c in resource.get("capabilities", [])]
64102
or None,
103+
attributes=(
104+
GroupAttributes._load(resource["attributes"], cognite_client=cognite_client)
105+
if isinstance(resource.get("attributes"), dict)
106+
else None
107+
),
65108
metadata=resource.get("metadata"),
66109
members=resource.get("members"),
67110
)
@@ -70,6 +113,8 @@ def dump(self, camel_case: bool = True) -> dict[str, Any]:
70113
dumped = super().dump(camel_case=camel_case)
71114
if self.capabilities is not None:
72115
dumped["capabilities"] = [c.dump(camel_case=camel_case) for c in self.capabilities]
116+
if self.attributes is not None:
117+
dumped["attributes"] = self.attributes.dump(camel_case=camel_case)
73118
return dumped
74119

75120

@@ -83,6 +128,7 @@ class Group(GroupCore):
83128
name (str): Name of the group.
84129
source_id (str | None): ID of the group in the source. If this is the same ID as a group in the IdP, a service account in that group will implicitly be a part of this group as well. Can not be used together with 'members'.
85130
capabilities (list[Capability] | None): List of capabilities (acls) this group should grant its users.
131+
attributes (GroupAttributes | None): Attributes of the group, this scopes down access based on the attributes specified.
86132
id (int | None): No description.
87133
is_deleted (bool | None): No description.
88134
deleted_time (int | None): No description.
@@ -96,6 +142,7 @@ def __init__(
96142
name: str,
97143
source_id: str | None = None,
98144
capabilities: list[Capability] | None = None,
145+
attributes: GroupAttributes | None = None,
99146
id: int | None = None,
100147
is_deleted: bool | None = None,
101148
deleted_time: int | None = None,
@@ -107,6 +154,7 @@ def __init__(
107154
name=name,
108155
source_id=source_id,
109156
capabilities=capabilities,
157+
attributes=attributes,
110158
metadata=metadata,
111159
members=members,
112160
)
@@ -125,6 +173,7 @@ def as_write(self) -> GroupWrite:
125173
name=self.name,
126174
source_id=self.source_id,
127175
capabilities=self.capabilities,
176+
attributes=self.attributes,
128177
metadata=self.metadata,
129178
members=self.members,
130179
)
@@ -147,6 +196,11 @@ def _load(cls, resource: dict, cognite_client: CogniteClient | None = None, allo
147196
return cls(
148197
name=resource["name"],
149198
source_id=resource.get("sourceId"),
199+
attributes=(
200+
GroupAttributes._load(resource["attributes"], cognite_client=cognite_client)
201+
if isinstance(resource.get("attributes"), dict)
202+
else None
203+
),
150204
capabilities=[Capability.load(c, allow_unknown) for c in resource.get("capabilities", [])] or None,
151205
id=resource.get("id"),
152206
is_deleted=resource.get("isDeleted"),
@@ -184,6 +238,7 @@ class GroupWrite(GroupCore):
184238
name (str): Name of the group.
185239
source_id (str | None): ID of the group in the source. If this is the same ID as a group in the IdP, a service account in that group will implicitly be a part of this group as well. Can not be used together with 'members'.
186240
capabilities (list[Capability] | None): List of capabilities (acls) this group should grant its users.
241+
attributes (GroupAttributes | None): Attributes of the group, this scopes down access based on the attributes specified.
187242
metadata (dict[str, str] | None): Custom, immutable application specific metadata. String key -> String value. Limits: Key are at most 32 bytes. Values are at most 512 bytes. Up to 16 key-value pairs. Total size is at most 4096.
188243
members (Literal['allUserAccounts'] | list[str] | None): Specifies which users are members of the group. Can not be used together with 'source_id'.
189244
"""
@@ -193,12 +248,14 @@ def __init__(
193248
name: str,
194249
source_id: str | None = None,
195250
capabilities: list[Capability] | None = None,
251+
attributes: GroupAttributes | None = None,
196252
metadata: dict[str, str] | None = None,
197253
members: Literal["allUserAccounts"] | list[str] | None = None,
198254
) -> None:
199255
super().__init__(
200256
name=name,
201257
source_id=source_id,
258+
attributes=attributes,
202259
capabilities=capabilities,
203260
metadata=metadata,
204261
members=members,
@@ -377,7 +434,10 @@ def __init__(self, subject: str, projects: list[ProjectSpec], capabilities: Proj
377434

378435
@classmethod
379436
def load(
380-
cls, api_response: dict[str, Any], cognite_client: CogniteClient | None = None, allow_unknown: bool = False
437+
cls,
438+
api_response: dict[str, Any],
439+
cognite_client: CogniteClient | None = None,
440+
allow_unknown: bool = False,
381441
) -> TokenInspection:
382442
return cls(
383443
subject=api_response["subject"],

tests/tests_unit/test_api/test_iam.py

Lines changed: 64 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44

55
from cognite.client.data_classes import Group, GroupList, SecurityCategory, SecurityCategoryList
66
from cognite.client.data_classes.capabilities import AllScope, GroupsAcl, ProjectCapability, ProjectCapabilityList
7-
from cognite.client.data_classes.iam import ProjectSpec, TokenInspection
7+
from cognite.client.data_classes.iam import GroupAttributes, ProjectSpec, TokenInspection
88
from tests.utils import jsgz_load
99

1010

@@ -29,12 +29,50 @@ def mock_groups(rsps, cognite_client):
2929
yield rsps
3030

3131

32+
@pytest.fixture
33+
def mock_groups_with_attributes(rsps, cognite_client):
34+
response_body = {
35+
"items": [
36+
{
37+
"name": "Production Engineers",
38+
"sourceId": "b7c9a5a4-99c2-4785-bed3-5e6ad9a78603",
39+
"capabilities": [{"groupsAcl": {"actions": ["LIST"], "scope": {"all": {}}}}],
40+
"id": 0,
41+
"isDeleted": False,
42+
"deletedTime": 0,
43+
"attributes": {
44+
"token": {
45+
"appIds": ["app1", "app2"],
46+
},
47+
"unknownProperty": "unknownValue",
48+
},
49+
}
50+
]
51+
}
52+
url_pattern = re.compile(re.escape(cognite_client.iam._get_base_url_with_base_path()) + "/groups.*")
53+
rsps.assert_all_requests_are_fired = False
54+
rsps.add(rsps.POST, url_pattern, status=200, json=response_body)
55+
rsps.add(rsps.GET, url_pattern, status=200, json=response_body)
56+
yield rsps
57+
58+
3259
class TestGroups:
3360
def test_list(self, cognite_client, mock_groups):
3461
res = cognite_client.iam.groups.list()
3562
assert isinstance(res, GroupList)
3663
assert mock_groups.calls[0].response.json()["items"] == res.dump(camel_case=True)
3764

65+
def test_list_groups_with_attributes(self, cognite_client, mock_groups_with_attributes):
66+
res = cognite_client.iam.groups.list()
67+
assert isinstance(res, GroupList)
68+
assert mock_groups_with_attributes.calls[0].response.json()["items"] == res.dump(camel_case=True)
69+
assert mock_groups_with_attributes.calls[0].response.json()["items"][0]["attributes"] == {
70+
"token": {
71+
"appIds": ["app1", "app2"],
72+
},
73+
"unknownProperty": "unknownValue",
74+
}
75+
3876
def test_create(self, cognite_client, mock_groups):
3977
my_group = Group(name="My Group", capabilities=[GroupsAcl([GroupsAcl.Action.List], AllScope())])
4078
res = cognite_client.iam.groups.create(my_group)
@@ -46,6 +84,31 @@ def test_create(self, cognite_client, mock_groups):
4684
} == jsgz_load(mock_groups.calls[0].request.body)
4785
assert mock_groups.calls[0].response.json()["items"][0] == res.dump(camel_case=True)
4886

87+
def test_create_with_attributes(self, cognite_client, mock_groups_with_attributes):
88+
# Construct attributes via loader to include unknown properties for pass-through
89+
attributes = GroupAttributes.load(
90+
{
91+
"token": {"appIds": ["app1", "app2"]},
92+
"unknownProperty": "unknownValue",
93+
}
94+
)
95+
my_group = Group(
96+
name="My Group",
97+
capabilities=[GroupsAcl([GroupsAcl.Action.List], AllScope())],
98+
attributes=attributes,
99+
)
100+
res = cognite_client.iam.groups.create(my_group)
101+
assert isinstance(res, Group)
102+
assert {
103+
"items": [
104+
{
105+
"name": "My Group",
106+
"capabilities": [{"groupsAcl": {"actions": ["LIST"], "scope": {"all": {}}}}],
107+
"attributes": {"token": {"appIds": ["app1", "app2"]}, "unknownProperty": "unknownValue"},
108+
}
109+
]
110+
} == jsgz_load(mock_groups_with_attributes.calls[0].request.body)
111+
49112
def test_create_multiple(self, cognite_client, mock_groups):
50113
res = cognite_client.iam.groups.create([1])
51114
assert isinstance(res, GroupList)

0 commit comments

Comments
 (0)