Skip to content

Commit 5ee6821

Browse files
Michael.richey/allow partial permissions (#421)
* Allow roles to be created/updated without certain permissions * Skip existing roles
1 parent e9325af commit 5ee6821

File tree

4 files changed

+165
-10
lines changed

4 files changed

+165
-10
lines changed

datadog_sync/commands/shared/options.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -359,6 +359,14 @@ def click_config_file_provider(ctx: Context, opts: CustomOptionClass, value: Non
359359
"user determines monitors have enough telemetry to trigger appropriately.",
360360
cls=CustomOptionClass,
361361
),
362+
option(
363+
"--allow-partial-permissions-roles",
364+
required=False,
365+
envvar=constants.DD_ALLOW_PARTIAL_PERMISSIONS_ROLES,
366+
help="Comma separated list of permissions to allow partial sync for roles. "
367+
"If a role has a permission that doesn't exist in the destination, it will be removed and retried.",
368+
cls=CustomOptionClass,
369+
),
362370
]
363371

364372

datadog_sync/constants.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
DD_VERIFY_DDR_STATUS = "DD_VERIFY_DDR_STATUS"
2424
DD_SHOW_PROGRESS_BAR = "DD_SHOW_PROGRESS_BAR"
2525
DD_VERIFY_SSL_CERTIFICATES = "DD_VERIFY_SSL_CERTIFICATES"
26+
DD_ALLOW_PARTIAL_PERMISSIONS_ROLES = "DD_ALLOW_PARTIAL_PERMISSIONS_ROLES"
2627

2728
LOCAL_STORAGE_TYPE = "local"
2829
S3_STORAGE_TYPE = "s3"

datadog_sync/model/roles.py

Lines changed: 149 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,12 @@
55

66
from __future__ import annotations
77
import copy
8+
import json
9+
import re
810
from typing import TYPE_CHECKING, Optional, List, Dict, Tuple, cast
911

1012
from datadog_sync.utils.base_resource import BaseResource, ResourceConfig
11-
from datadog_sync.utils.resource_utils import CustomClientHTTPError, check_diff
13+
from datadog_sync.utils.resource_utils import CustomClientHTTPError, check_diff, SkipResource
1214

1315
if TYPE_CHECKING:
1416
from datadog_sync.utils.custom_client import CustomClient
@@ -84,9 +86,52 @@ async def create_resource(self, _id, resource) -> Tuple[str, Dict]:
8486
# role does not exist at the destination, so create it
8587
if role_name not in self.destination_roles_mapping:
8688
destination_client = self.config.destination_client
87-
payload = {"data": resource}
88-
resp = await destination_client.post(self.resource_config.base_path, payload)
89-
return _id, resp["data"]
89+
90+
# Retry loop to handle multiple invalid permissions
91+
max_retries = 1 + len(self.config.allow_partial_permissions_roles)
92+
retry_count = 0
93+
94+
while retry_count < max_retries:
95+
payload = {"data": resource}
96+
try:
97+
resp = await destination_client.post(self.resource_config.base_path, payload)
98+
return _id, resp["data"]
99+
except CustomClientHTTPError as e:
100+
if e.status_code == 400 and self.config.allow_partial_permissions_roles:
101+
# Try to parse the invalid permission from the error
102+
invalid_permission = self._parse_invalid_permission_from_error(str(e))
103+
104+
if invalid_permission and invalid_permission in self.config.allow_partial_permissions_roles:
105+
# Check if we removed the permission successfully
106+
if self._remove_permission_from_resource(resource, invalid_permission):
107+
self.config.logger.warning(
108+
f"Trying again without '{invalid_permission}' permission for role '{role_name}'"
109+
)
110+
111+
# Check if the modified resource now matches an existing destination role
112+
# This can happen if we already synced the role without this permission before
113+
if role_name in self.destination_roles_mapping:
114+
matching_destination_role = self.destination_roles_mapping[role_name]
115+
role_copy = copy.deepcopy(resource)
116+
role_copy.update(matching_destination_role)
117+
118+
# If there's no diff, the role already exists without this permission
119+
if not check_diff(self.resource_config, resource, role_copy):
120+
raise SkipResource(
121+
_id,
122+
self.resource_type,
123+
f"Role '{role_name}' already exists at destination "
124+
"without '{invalid_permission}' permission",
125+
)
126+
127+
retry_count += 1
128+
continue # Retry with the updated resource
129+
130+
# If we couldn't handle the error, re-raise it
131+
raise
132+
133+
# If we exhausted retries, raise an error
134+
raise Exception(f"Exceeded maximum retries ({max_retries}) while creating role '{role_name}'")
90135

91136
# role already exists at the destination
92137
matching_destination_role = self.destination_roles_mapping[role_name]
@@ -108,21 +153,115 @@ async def create_resource(self, _id, resource) -> Tuple[str, Dict]:
108153

109154
async def update_resource(self, _id: str, resource: Dict) -> Tuple[str, Dict]:
110155
destination_client = self.config.destination_client
156+
role_name = resource["attributes"]["name"]
111157
resource["id"] = self.config.state.destination[self.resource_type][_id]["id"]
112-
payload = {"data": resource}
113-
resp = await destination_client.patch(
114-
self.resource_config.base_path + f"/{self.config.state.destination[self.resource_type][_id]['id']}",
115-
payload,
116-
)
117158

118-
return _id, resp["data"]
159+
# Retry loop to handle multiple invalid permissions
160+
max_retries = 1 + len(self.config.allow_partial_permissions_roles)
161+
retry_count = 0
162+
163+
while retry_count < max_retries:
164+
payload = {"data": resource}
165+
try:
166+
resp = await destination_client.patch(
167+
self.resource_config.base_path + f"/{self.config.state.destination[self.resource_type][_id]['id']}",
168+
payload,
169+
)
170+
return _id, resp["data"]
171+
except CustomClientHTTPError as e:
172+
if e.status_code == 400 and self.config.allow_partial_permissions_roles:
173+
# Try to parse the invalid permission from the error
174+
invalid_permission = self._parse_invalid_permission_from_error(str(e))
175+
176+
if invalid_permission and invalid_permission in self.config.allow_partial_permissions_roles:
177+
# Check if we removed the permission successfully
178+
if self._remove_permission_from_resource(resource, invalid_permission):
179+
self.config.logger.warning(
180+
f"Trying again without '{invalid_permission}' permission for role '{role_name}'"
181+
)
182+
183+
# Check if the modified resource now matches the existing destination state
184+
# This can happen if we already synced the role without this permission before
185+
if _id in self.config.state.destination[self.resource_type]:
186+
destination_resource = self.config.state.destination[self.resource_type][_id]
187+
188+
# If there's no diff, the role already exists without this permission
189+
if not check_diff(self.resource_config, resource, destination_resource):
190+
raise SkipResource(
191+
_id,
192+
self.resource_type,
193+
f"Role '{role_name}' already exists at destination "
194+
"without '{invalid_permission}' permission",
195+
)
196+
197+
retry_count += 1
198+
continue # Retry with the updated resource
199+
200+
# If we couldn't handle the error, re-raise it
201+
raise
202+
203+
# If we exhausted retries, raise an error
204+
raise Exception(f"Exceeded maximum retries ({max_retries}) while updating role '{role_name}'")
119205

120206
async def delete_resource(self, _id: str) -> None:
121207
destination_client = self.config.destination_client
122208
await destination_client.delete(
123209
self.resource_config.base_path + f"/{self.config.state.destination[self.resource_type][_id]['id']}"
124210
)
125211

212+
def _parse_invalid_permission_from_error(self, error_message: str) -> Optional[str]:
213+
"""Parse the invalid permission name from a 400 error message.
214+
215+
Example error: '400 Bad Request - {"title":"Generic Error","detail":"invalid UUID [assistant_access]"}'
216+
Returns: "assistant_access"
217+
"""
218+
try:
219+
# Try to extract JSON from error message
220+
match = re.search(r"\{.*\}", error_message)
221+
if not match:
222+
return None
223+
224+
error_json = json.loads(match.group(0))
225+
226+
# Look for "invalid UUID [permission_name]" pattern
227+
detail = error_json.get("detail", "")
228+
uuid_match = re.search(r"invalid UUID \[([^\]]+)\]", detail)
229+
if uuid_match:
230+
return uuid_match.group(1)
231+
232+
# Also check in errors array if present
233+
errors = error_json.get("errors", [])
234+
for error in errors:
235+
if isinstance(error, str):
236+
uuid_match = re.search(r"invalid UUID \[([^\]]+)\]", error)
237+
if uuid_match:
238+
return uuid_match.group(1)
239+
elif isinstance(error, dict):
240+
detail = error.get("detail", "")
241+
uuid_match = re.search(r"invalid UUID \[([^\]]+)\]", detail)
242+
if uuid_match:
243+
return uuid_match.group(1)
244+
except (json.JSONDecodeError, KeyError, AttributeError):
245+
pass
246+
247+
return None
248+
249+
def _remove_permission_from_resource(self, resource: Dict, permission_id: str) -> bool:
250+
"""Remove a permission from the resource's relationships.
251+
252+
Returns True if permission was found and removed, False otherwise.
253+
"""
254+
if "relationships" not in resource or "permissions" not in resource["relationships"]:
255+
return False
256+
257+
permissions_data = resource["relationships"]["permissions"].get("data", [])
258+
original_length = len(permissions_data)
259+
260+
# Filter out the permission
261+
resource["relationships"]["permissions"]["data"] = [p for p in permissions_data if p.get("id") != permission_id]
262+
263+
return len(resource["relationships"]["permissions"]["data"]) < original_length
264+
126265
async def remap_permissions(self, resource):
127266
if not self.destination_permissions:
128267
try:

datadog_sync/utils/configuration.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@ class Configuration(object):
5959
verify_ddr_status: bool
6060
backup_before_reset: bool
6161
show_progress_bar: bool
62+
allow_partial_permissions_roles: List[str] = field(default_factory=list)
6263
resources: Dict[str, BaseResource] = field(default_factory=dict)
6364
resources_arg: List[str] = field(default_factory=list)
6465

@@ -173,6 +174,11 @@ def build_config(cmd: Command, **kwargs: Optional[Any]) -> Configuration:
173174
backup_before_reset = not kwargs.get("do_not_backup")
174175
show_progress_bar = kwargs.get("show_progress_bar")
175176

177+
# Parse allow_partial_permissions_roles
178+
allow_partial_permissions_roles = []
179+
if allow_partial_str := kwargs.get("allow_partial_permissions_roles"):
180+
allow_partial_permissions_roles = [p.strip() for p in allow_partial_str.split(",")]
181+
176182
cleanup = kwargs.get("cleanup")
177183
if cleanup:
178184
cleanup = {
@@ -251,6 +257,7 @@ def build_config(cmd: Command, **kwargs: Optional[Any]) -> Configuration:
251257
verify_ddr_status=verify_ddr_status,
252258
backup_before_reset=backup_before_reset,
253259
show_progress_bar=show_progress_bar,
260+
allow_partial_permissions_roles=allow_partial_permissions_roles,
254261
)
255262

256263
# Initialize resource classes

0 commit comments

Comments
 (0)