Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
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
12 changes: 12 additions & 0 deletions datadog_sync/commands/shared/options.py
Original file line number Diff line number Diff line change
Expand Up @@ -591,6 +591,18 @@ def click_config_file_provider(ctx: Context, opts: CustomOptionClass, value: Non
help="Allow self-lockout when syncing restriction policies.",
cls=CustomOptionClass,
),
option(
"--use-v1-user-api",
required=False,
envvar=constants.DD_USE_V1_USER_API,
type=bool,
default=False,
show_default=True,
help="Create users via the v1 API (/api/v1/user) instead of v2. v2 cannot set a "
"handle (it derives one from the email), which collides when users share an email; "
"v1 accepts an explicit handle so each user keeps its own. Off by default.",
cls=CustomOptionClass,
),
]


Expand Down
1 change: 1 addition & 0 deletions datadog_sync/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
DD_SHOW_PROGRESS_BAR = "DD_SHOW_PROGRESS_BAR"
DD_VERIFY_SSL_CERTIFICATES = "DD_VERIFY_SSL_CERTIFICATES"
DD_ALLOW_PARTIAL_PERMISSIONS_ROLES = "DD_ALLOW_PARTIAL_PERMISSIONS_ROLES"
DD_USE_V1_USER_API = "DD_USE_V1_USER_API"
DD_SYNC_JSON = "DD_SYNC_JSON"
DD_DATADOG_HOST_OVERRIDE = "DD_DATADOG_HOST_OVERRIDE"

Expand Down
68 changes: 64 additions & 4 deletions datadog_sync/model/users.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,11 @@ class Users(BaseResource):
"attributes.status",
"attributes.verified",
"attributes.service_account",
"attributes.handle",
# NOTE: attributes.handle is deliberately NOT excluded here. It is the
# user mapping key (resource_mapping_key below) and the payload for the
# v1 user creation, so it must survive prep_resource. It is popped
# manually before the v2 POST/PATCH (v2 treats handle as read-only) and
# kept out of update diffs via deep_diff_config.exclude_regex_paths below.
"attributes.icon",
"attributes.modified_at",
"attributes.mfa_enabled",
Expand All @@ -40,7 +44,11 @@ class Users(BaseResource):
"relationships.org",
"relationships.team_roles",
],
resource_mapping_key="attributes.email",
resource_mapping_key="attributes.handle",
# Handle is read-only in v2 and is popped from the create/update payload;
# exclude it from update diffs so a source-vs-destination handle difference
# never drives a spurious PATCH of a field v2 will not accept.
deep_diff_config={"ignore_order": True, "exclude_regex_paths": [r".*\['handle'\]"]},
)
# Additional Users specific attributes
pagination_config = PaginationConfig(
Expand Down Expand Up @@ -80,12 +88,63 @@ async def create_resource(self, _id: str, resource: Dict) -> Tuple[str, Dict]:
self.config.state.destination[self.resource_type][_id] = self._existing_resources_map[key]
return await self.update_resource(_id, resource)

attributes = resource["attributes"]
if self.config.use_v1_user_api:
# v2 create cannot set a handle (it derives one from the email), which
# collapses distinct-handle users that share an email onto one handle.
# v1 accepts an explicit handle, so each user keeps its own.
return _id, await self._create_via_v1(
attributes.get("handle"), attributes.get("name"), attributes.get("email")
)

destination_client = self.config.destination_client
resource["attributes"].pop("disabled", None)
# handle is read-only in v2 (derived from email) and must not be sent.
attributes.pop("disabled", None)
attributes.pop("handle", None)
resp = await destination_client.post(self.resource_config.base_path, {"data": resource})

return _id, resp["data"]

async def _create_via_v1(self, handle: Optional[str], name: Optional[str], email: Optional[str]) -> Dict:
"""Create the user via the v1 API, which accepts an explicit handle.

v2 ``POST /api/v2/users`` cannot set a handle (it is derived from the
email), so users that share an email collapse onto one handle and later
creates 409 — and the v2 "winner" is created with the wrong handle. v1
``POST /api/v1/user`` accepts a distinct handle. The v1 response is the
legacy user shape, so re-resolve the v2 UUID by handle and return that
record — state and downstream references (roles, team_memberships) key
on the v2 UUID.
"""
if not handle:
raise ValueError("v1 user creation requires a handle")

destination_client = self.config.destination_client
await destination_client.post("/api/v1/user", {"handle": handle, "name": name, "email": email})

user = await self._get_destination_user_by_handle(handle)
if user is None:
raise ValueError("v1-created user not found by handle after create")
return user

async def _get_destination_user_by_handle(self, handle: str) -> Optional[Dict]:
"""Return the destination user whose handle matches exactly, or None.

Transient HTTP errors are already retried by the client's
``request_with_retry``; this adds a small, sleep-free re-query loop only
to absorb read-after-write visibility lag after a v1 create.
"""
destination_client = self.config.destination_client
for _ in range(3):
resp = await destination_client.paginated_request(destination_client.get)(
self.resource_config.base_path,
pagination_config=self.pagination_config,
params={"filter": handle},
)
for user in resp:
if user.get("attributes", {}).get("handle") == handle:
return user
return None

async def update_resource(self, _id: str, resource: Dict) -> Tuple[str, Dict]:
destination_client = self.config.destination_client

Expand All @@ -94,6 +153,7 @@ async def update_resource(self, _id: str, resource: Dict) -> Tuple[str, Dict]:
await self.update_user_roles(self.config.state.destination[self.resource_type][_id]["id"], diff)
resource["id"] = self.config.state.destination[self.resource_type][_id]["id"]
resource.pop("relationships", None)
resource["attributes"].pop("handle", None)
resp = await destination_client.patch(
self.resource_config.base_path + f"/{self.config.state.destination[self.resource_type][_id]['id']}",
{"data": resource},
Expand Down
3 changes: 3 additions & 0 deletions datadog_sync/utils/configuration.py
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,7 @@ class Configuration(object):
max_workers_per_type: Dict[str, int] = field(default_factory=dict)
command: str = ""
allow_partial_permissions_roles: List[str] = field(default_factory=list)
use_v1_user_api: bool = False
resources: Dict[str, BaseResource] = field(default_factory=dict)
resources_arg: List[str] = field(default_factory=list)
# --id-file: id-targeted import via stdin or file payload.
Expand Down Expand Up @@ -435,6 +436,7 @@ def build_config(cmd: Command, **kwargs: Optional[Any]) -> Configuration:
max_workers_per_type = _parse_max_workers_per_type(max_workers_per_type_raw, known_resource_types)
create_global_downtime = kwargs.get("create_global_downtime")
validate = kwargs.get("validate")
use_v1_user_api = kwargs.get("use_v1_user_api") or False
verify_ddr_status = kwargs.get("verify_ddr_status")
backup_before_reset = not kwargs.get("do_not_backup")
show_progress_bar = kwargs.get("show_progress_bar")
Expand Down Expand Up @@ -691,6 +693,7 @@ def build_config(cmd: Command, **kwargs: Optional[Any]) -> Configuration:
emit_json=emit_json,
command=cmd.value,
allow_partial_permissions_roles=allow_partial_permissions_roles,
use_v1_user_api=use_v1_user_api,
id_payload=id_payload,
max_concurrent_reads=max_concurrent_reads,
transient_failure_threshold_pct=transient_failure_threshold_pct,
Expand Down
1 change: 1 addition & 0 deletions setup.cfg
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ tests =
black==24.3.0
pytest==8.1.1
pytest-black
pytest-cov
pytest-console-scripts
pytest-recording==0.13.2
pytest-retry==1.7.0
Expand Down
Loading