Skip to content

Commit 95c5d0e

Browse files
Merge pull request #1121 from gooddata/snapshot-master-82a1c2d1-to-rel/dev
[bot] Merge master/82a1c2d1 into rel/dev
2 parents 3f354e4 + 82a1c2d commit 95c5d0e

19 files changed

+908
-317
lines changed

gooddata-pipelines/gooddata_pipelines/provisioning/entities/users/models/permissions.py

Lines changed: 93 additions & 72 deletions
Original file line numberDiff line numberDiff line change
@@ -1,103 +1,101 @@
11
# (C) 2025 GoodData Corporation
2-
from dataclasses import dataclass
2+
from abc import abstractmethod
33
from enum import Enum
4-
from typing import Any, Iterator, TypeAlias
4+
from typing import Any, Iterator, TypeAlias, TypeVar
55

6+
import attrs
67
from gooddata_sdk.catalog.identifier import CatalogAssigneeIdentifier
78
from gooddata_sdk.catalog.permission.declarative_model.permission import (
89
CatalogDeclarativeSingleWorkspacePermission,
910
CatalogDeclarativeWorkspacePermissions,
1011
)
12+
from pydantic import BaseModel
1113

1214
from gooddata_pipelines.provisioning.utils.exceptions import BaseUserException
1315

14-
# TODO: refactor the full load and incremental load models to reuse as much as possible
15-
# TODO: use pydantic models instead of dataclasses?
16-
# TODO: make the validation logic more readable (as in PermissionIncrementalLoad)
17-
1816
TargetsPermissionDict: TypeAlias = dict[str, dict[str, bool]]
17+
ConstructorType = TypeVar("ConstructorType", bound="ConstructorMixin")
1918

2019

21-
class PermissionType(Enum):
20+
class PermissionType(str, Enum):
21+
# NOTE: Start using StrEnum with Python 3.11
2222
user = "user"
2323
user_group = "userGroup"
2424

2525

26-
@dataclass(frozen=True)
27-
class PermissionIncrementalLoad:
28-
permission: str
29-
workspace_id: str
30-
id: str
31-
type: PermissionType
32-
is_active: bool
26+
class ConstructorMixin:
27+
@staticmethod
28+
def _get_id_and_type(
29+
permission: dict[str, Any],
30+
) -> tuple[str, PermissionType]:
31+
user_id: str | None = permission.get("user_id")
32+
user_group_id: str | None = permission.get("ug_id")
33+
if user_id and user_group_id:
34+
raise ValueError("Only one of user_id or ug_id must be present")
35+
elif user_id:
36+
return user_id, PermissionType.user
37+
elif user_group_id:
38+
return user_group_id, PermissionType.user_group
39+
else:
40+
raise ValueError("Either user_id or ug_id must be present")
3341

3442
@classmethod
3543
def from_list_of_dicts(
36-
cls, data: list[dict[str, Any]]
37-
) -> list["PermissionIncrementalLoad"]:
38-
"""Creates a list of User objects from list of dicts."""
39-
id: str
44+
cls: type[ConstructorType], data: list[dict[str, Any]]
45+
) -> list[ConstructorType]:
46+
"""Creates a list of instances from list of dicts."""
47+
# NOTE: We can use typing.Self for the return type in Python 3.11
4048
permissions = []
4149
for permission in data:
42-
user_id: str | None = permission.get("user_id")
43-
user_group_id: str | None = permission.get("ug_id")
44-
45-
if user_id is not None:
46-
target_type = PermissionType.user
47-
id = user_id
48-
elif user_group_id is not None:
49-
target_type = PermissionType.user_group
50-
id = user_group_id
51-
52-
permissions.append(
53-
PermissionIncrementalLoad(
54-
permission=permission["ws_permissions"],
55-
workspace_id=permission["ws_id"],
56-
id=id,
57-
type=target_type,
58-
is_active=str(permission["is_active"]).lower() == "true",
59-
)
60-
)
50+
permissions.append(cls.from_dict(permission))
6151
return permissions
6252

53+
@classmethod
54+
@abstractmethod
55+
def from_dict(cls, data: dict[str, Any]) -> Any:
56+
"""Construction form a dictionary to be implemented by subclasses."""
57+
pass
58+
6359

64-
@dataclass(frozen=True)
65-
class PermissionFullLoad:
60+
class PermissionIncrementalLoad(BaseModel, ConstructorMixin):
6661
permission: str
6762
workspace_id: str
68-
id: str
69-
type: PermissionType
63+
id_: str
64+
type_: PermissionType
65+
is_active: bool
7066

7167
@classmethod
72-
def from_list_of_dicts(
73-
cls, data: list[dict[str, Any]]
74-
) -> list["PermissionFullLoad"]:
75-
"""Creates a list of User objects from list of dicts."""
76-
permissions = []
77-
for permission in data:
78-
id = (
79-
permission["user_id"]
80-
if permission["user_id"]
81-
else permission["ug_id"]
82-
)
68+
def from_dict(cls, data: dict[str, Any]) -> "PermissionIncrementalLoad":
69+
"""Returns an instance of PermissionIncrementalLoad from a dictionary."""
70+
id_, target_type = cls._get_id_and_type(data)
71+
return cls(
72+
permission=data["ws_permissions"],
73+
workspace_id=data["ws_id"],
74+
id_=id_,
75+
type_=target_type,
76+
is_active=data["is_active"],
77+
)
8378

84-
if permission["user_id"]:
85-
target_type = PermissionType.user
86-
else:
87-
target_type = PermissionType.user_group
88-
89-
permissions.append(
90-
PermissionFullLoad(
91-
permission=permission["ws_permissions"],
92-
workspace_id=permission["ws_id"],
93-
id=id,
94-
type=target_type,
95-
)
96-
)
97-
return permissions
9879

80+
class PermissionFullLoad(BaseModel, ConstructorMixin):
81+
permission: str
82+
workspace_id: str
83+
id_: str
84+
type_: PermissionType
85+
86+
@classmethod
87+
def from_dict(cls, data: dict[str, Any]) -> "PermissionFullLoad":
88+
"""Returns an instance of PermissionFullLoad from a dictionary."""
89+
id_, target_type = cls._get_id_and_type(data)
90+
return cls(
91+
permission=data["ws_permissions"],
92+
workspace_id=data["ws_id"],
93+
id_=id_,
94+
type_=target_type,
95+
)
9996

100-
@dataclass
97+
98+
@attrs.define
10199
class PermissionDeclaration:
102100
users: TargetsPermissionDict
103101
user_groups: TargetsPermissionDict
@@ -192,23 +190,25 @@ def to_sdk_api(self) -> CatalogDeclarativeWorkspacePermissions:
192190
permissions=permission_declarations
193191
)
194192

195-
def add_permission(self, permission: PermissionIncrementalLoad) -> None:
193+
def add_incremental_permission(
194+
self, permission: PermissionIncrementalLoad
195+
) -> None:
196196
"""
197197
Adds WSPermission object into respective field within the instance.
198198
Handles duplicate permissions and different combinations of input
199199
and upstream is_active permission states.
200200
"""
201201
target_dict = (
202202
self.users
203-
if permission.type == PermissionType.user
203+
if permission.type_ == PermissionType.user
204204
else self.user_groups
205205
)
206206

207-
if permission.id not in target_dict:
208-
target_dict[permission.id] = {}
207+
if permission.id_ not in target_dict:
208+
target_dict[permission.id_] = {}
209209

210210
is_active = permission.is_active
211-
target_permissions = target_dict[permission.id]
211+
target_permissions = target_dict[permission.id_]
212212
permission_value = permission.permission
213213

214214
if permission_value not in target_permissions:
@@ -225,6 +225,27 @@ def add_permission(self, permission: PermissionIncrementalLoad) -> None:
225225
)
226226
target_permissions[permission_value] = is_active
227227

228+
def add_full_load_permission(self, permission: PermissionFullLoad) -> None:
229+
"""
230+
Adds WSPermission object into respective field within the instance.
231+
Handles duplicate permissions and different combinations of input
232+
and upstream is_active permission states.
233+
"""
234+
target_dict = (
235+
self.users
236+
if permission.type_ == PermissionType.user
237+
else self.user_groups
238+
)
239+
240+
if permission.id_ not in target_dict:
241+
target_dict[permission.id_] = {}
242+
243+
target_permissions = target_dict[permission.id_]
244+
permission_value = permission.permission
245+
246+
if permission_value not in target_permissions:
247+
target_permissions[permission_value] = True
248+
228249
def upsert(self, other: "PermissionDeclaration") -> None:
229250
"""
230251
Modifies the owner object by merging with the other.

gooddata-pipelines/gooddata_pipelines/provisioning/entities/users/permissions.py

Lines changed: 48 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22

33
"""Module for provisioning user permissions in GoodData workspaces."""
44

5+
from typing import TypeVar
6+
57
from gooddata_pipelines.api.exceptions import GoodDataApiException
68
from gooddata_pipelines.provisioning.entities.users.models.permissions import (
79
PermissionDeclaration,
@@ -14,6 +16,11 @@
1416
from gooddata_pipelines.provisioning.provisioning import Provisioning
1517
from gooddata_pipelines.provisioning.utils.exceptions import BaseUserException
1618

19+
# Type variable for permission models (PermissionIncrementalLoad or PermissionFullLoad)
20+
PermissionModel = TypeVar(
21+
"PermissionModel", PermissionIncrementalLoad, PermissionFullLoad
22+
)
23+
1724

1825
class PermissionProvisioner(
1926
Provisioning[PermissionFullLoad, PermissionIncrementalLoad]
@@ -72,7 +79,7 @@ def _get_upstream_declarations(
7279

7380
@staticmethod
7481
def _construct_declarations(
75-
permissions: list[PermissionIncrementalLoad],
82+
permissions: list[PermissionIncrementalLoad] | list[PermissionFullLoad],
7683
) -> WSPermissionsDeclarations:
7784
"""Constructs workspace permission declarations from the input permissions."""
7885
ws_dict: WSPermissionsDeclarations = {}
@@ -82,33 +89,40 @@ def _construct_declarations(
8289
if ws_id not in ws_dict:
8390
ws_dict[ws_id] = PermissionDeclaration({}, {})
8491

85-
ws_dict[ws_id].add_permission(permission)
92+
if isinstance(permission, PermissionIncrementalLoad):
93+
ws_dict[ws_id].add_incremental_permission(permission)
94+
elif isinstance(permission, PermissionFullLoad):
95+
ws_dict[ws_id].add_full_load_permission(permission)
96+
else:
97+
raise ValueError(f"Invalid permission type: {type(permission)}")
8698
return ws_dict
8799

88100
def _check_user_group_exists(self, ug_id: str) -> None:
89101
"""Checks if user group with provided ID exists."""
90102
self._api._sdk.catalog_user.get_user_group(ug_id)
91103

92104
def _validate_permission(
93-
self, permission: PermissionIncrementalLoad
105+
self, permission: PermissionFullLoad | PermissionIncrementalLoad
94106
) -> None:
95107
"""Validates if the permission is correctly defined."""
96-
if permission.type == PermissionType.user:
97-
self._api.get_user(permission.id, error_message="User not found")
108+
if permission.type_ == PermissionType.user:
109+
self._api.get_user(permission.id_, error_message="User not found")
98110
else:
99111
self._api.get_user_group(
100-
permission.id, error_message="User group not found"
112+
permission.id_, error_message="User group not found"
101113
)
102114

103115
self._api.get_workspace(
104116
permission.workspace_id, error_message="Workspace not found"
105117
)
106118

107119
def _filter_invalid_permissions(
108-
self, permissions: list[PermissionIncrementalLoad]
109-
) -> list[PermissionIncrementalLoad]:
120+
self,
121+
permissions: list[PermissionModel],
122+
) -> list[PermissionModel]:
110123
"""Filters out invalid permissions from the input list."""
111-
valid_permissions: list[PermissionIncrementalLoad] = []
124+
valid_permissions: list[PermissionModel] = []
125+
112126
for permission in permissions:
113127
try:
114128
self._validate_permission(permission)
@@ -121,13 +135,15 @@ def _filter_invalid_permissions(
121135
valid_permissions.append(permission)
122136
return valid_permissions
123137

124-
def _manage_permissions(
125-
self, permissions: list[PermissionIncrementalLoad]
126-
) -> None:
127-
"""Manages permissions for a list of workspaces.
128-
Modify upstream workspace declarations for each input workspace and skip non-existent ws_ids
138+
def _provision_incremental_load(self) -> None:
139+
"""Provisiones permissions for a list of workspaces.
140+
141+
Modifies existing upstream workspace permission declarations for each
142+
input workspace and skips rest of the workspaces.
129143
"""
130-
valid_permissions = self._filter_invalid_permissions(permissions)
144+
valid_permissions = self._filter_invalid_permissions(
145+
self.source_group_incremental
146+
)
131147

132148
input_declarations = self._construct_declarations(valid_permissions)
133149

@@ -145,9 +161,21 @@ def _manage_permissions(
145161
self._api.put_declarative_permissions(ws_id, ws_permissions)
146162
self.logger.info(f"Updated permissions for workspace {ws_id}")
147163

148-
def _provision_incremental_load(self) -> None:
149-
"""Provision permissions based on the source group."""
150-
self._manage_permissions(self.source_group_incremental)
151-
152164
def _provision_full_load(self) -> None:
153-
raise NotImplementedError("Not implemented yet.")
165+
"""Provisions permissions for selected of workspaces.
166+
167+
Modifies upstream workspace declarations for each input workspace and
168+
skips non-existent workspace ids. Overwrites any existing configuration
169+
of the workspace permissions.
170+
"""
171+
valid_permissions = self._filter_invalid_permissions(
172+
self.source_group_full
173+
)
174+
175+
input_declarations = self._construct_declarations(valid_permissions)
176+
177+
for ws_id, declaration in input_declarations.items():
178+
ws_permissions = declaration.to_sdk_api()
179+
180+
self._api.put_declarative_permissions(ws_id, ws_permissions)
181+
self.logger.info(f"Updated permissions for workspace {ws_id}")

0 commit comments

Comments
 (0)