Skip to content
Open
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
8 changes: 0 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,6 @@ Datadog cli tool to sync resources across organizations.
- [Config file](#config-file)
- [Cleanup flag](#cleanup-flag)
- [Verify DDR Status Flag](#verify-ddr-status-flag)
- [Create users via the v1 API](#create-users-via-the-v1-api)
- [State Files](#state-files)
- [Supported resources](#supported-resources)
- [Best practices](#best-practices)
Expand Down Expand Up @@ -211,13 +210,6 @@ For example, `ResourceA` and `ResourceB` are imported and synced, followed by de
By default all commands check the Datadog Disaster Recovery (DDR) status of both the source and destination organizations before running. This behavior is controlled by the boolean flag `--verify-ddr-status` or the environment variable `DD_VERIFY_DDR_STATUS`.


#### Create users via the v1 API

The destination enforces user uniqueness on the `handle`, not the email — multiple users may share an email address. The v2 user create endpoint (`POST /api/v2/users`) does not accept a `handle` (it derives one from the email), so users that share an email collapse onto a single handle: the first create takes a handle that may belong to another user, and later creates for that email return an HTTP 409.

Passing `--use-v1-user-api` (or setting `DD_USE_V1_USER_API=true`) makes `sync` create users via the v1 user endpoint (`POST /api/v1/user`), which accepts an explicit handle, so every user keeps its own handle and no create collides. The flag is off by default.


#### Preserving a source handle during v2 user creation

The v2 user create endpoint (`POST /api/v2/users`) normally derives the destination `handle` from the email. Some destination organizations support creating users with an explicit `handle` instead — for example, a non-email-shaped value such as `user_test`.
Expand Down
5 changes: 1 addition & 4 deletions datadog_sync/commands/shared/options.py
Original file line number Diff line number Diff line change
Expand Up @@ -597,10 +597,7 @@ def click_config_file_provider(ctx: Context, opts: CustomOptionClass, value: Non
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.",
hidden=True,
cls=CustomOptionClass,
),
option(
Expand Down
82 changes: 5 additions & 77 deletions datadog_sync/model/users.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@
# Copyright 2019 Datadog, Inc.

from __future__ import annotations
import asyncio
from typing import TYPE_CHECKING, Any, Optional, List, Dict, Tuple, cast

from datadog_sync.utils.base_resource import BaseResource, ResourceConfig
Expand Down Expand Up @@ -43,12 +42,11 @@ class Users(BaseResource):
"attributes.verified",
"attributes.service_account",
# 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 PATCH (v2 treats handle as read-only there) and
# before the v2 POST unless --preserve-user-handle opts into passing it
# through, and kept out of update diffs via
# deep_diff_config.exclude_regex_paths below.
# user mapping key (resource_mapping_key below), so it must survive
# prep_resource. It is popped manually before the v2 PATCH (v2 treats
# handle as read-only there) and before the v2 POST unless
# --preserve-user-handle opts into passing it through, and kept out of
# update diffs via deep_diff_config.exclude_regex_paths below.
"attributes.icon",
"attributes.modified_at",
"attributes.mfa_enabled",
Expand All @@ -69,7 +67,6 @@ class Users(BaseResource):
page_size=500,
)
roles_path: str = "/api/v2/roles/{}/users"
user_lookup_retry_delays: Tuple[float, ...] = (1.0, 2.0)

async def get_resources(self, client: CustomClient) -> List[Dict]:
resp = await client.paginated_request(client.get)(
Expand Down Expand Up @@ -104,25 +101,6 @@ async def create_resource(self, _id: str, resource: Dict) -> Tuple[str, Dict]:
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.
try:
user = await self._create_via_v1(
attributes.get("handle"),
attributes.get("name"),
attributes.get("email"),
resource.get("relationships", {}).get("roles", {}).get("data", []),
)
except UserRoleAssignmentError as e:
# Preserve the reconciled UUID and successful memberships so
# downstream resources can still resolve this user. Re-raising
# makes the apply handler count and emit the partial failure.
self.config.state.destination[self.resource_type][_id] = e.user
raise
return _id, user

destination_client = self.config.destination_client
# handle is normally read-only in v2 (derived from email) and must not be
# sent, unless --preserve-user-handle opts into passing a non-empty value
Expand All @@ -138,35 +116,6 @@ async def create_resource(self, _id: str, resource: Dict) -> Tuple[str, Dict]:
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],
desired_roles: Optional[List[Dict]] = None,
) -> 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")
await self._assign_missing_roles(user, desired_roles or [])
return user

async def _assign_missing_roles(self, user: Dict, desired_roles: List[Dict]) -> None:
"""Assign missing roles and keep the reconciled user state accurate."""
existing_roles = user.setdefault("relationships", {}).setdefault("roles", {}).setdefault("data", [])
Expand Down Expand Up @@ -204,27 +153,6 @@ def _merge_role_state(updated_user: Dict, role_state: Dict) -> Dict:
updated_role_ids.add(role_id)
return updated_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 bounded re-query loop with delays to
absorb read-after-write visibility lag after a v1 create.
"""
destination_client = self.config.destination_client
for attempt in range(len(self.user_lookup_retry_delays) + 1):
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
if attempt < len(self.user_lookup_retry_delays):
await asyncio.sleep(self.user_lookup_retry_delays[attempt])
return None

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

Expand Down
9 changes: 6 additions & 3 deletions datadog_sync/utils/configuration.py
Original file line number Diff line number Diff line change
Expand Up @@ -109,7 +109,6 @@ 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
preserve_user_handle: bool = False
resources: Dict[str, BaseResource] = field(default_factory=dict)
resources_arg: List[str] = field(default_factory=list)
Expand Down Expand Up @@ -437,7 +436,12 @@ 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
if kwargs.get("use_v1_user_api"):
raise click.UsageError(
"--use-v1-user-api and DD_USE_V1_USER_API are no longer supported. "
"Use --preserve-user-handle when the destination supports explicit "
"handles during user creation."
)
preserve_user_handle = kwargs.get("preserve_user_handle") or False
verify_ddr_status = kwargs.get("verify_ddr_status")
backup_before_reset = not kwargs.get("do_not_backup")
Expand Down Expand Up @@ -695,7 +699,6 @@ 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,
preserve_user_handle=preserve_user_handle,
id_payload=id_payload,
max_concurrent_reads=max_concurrent_reads,
Expand Down
1 change: 0 additions & 1 deletion tests/unit/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,5 +25,4 @@ def mock_config():
config.logger = MagicMock()
# Explicit default so no test is affected by a stray truthy MagicMock attribute.
config.preserve_user_handle = False
config.use_v1_user_api = False # ditto, added for consistency while touching this fixture
return config
Loading