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
11 changes: 11 additions & 0 deletions datadog_sync/commands/shared/options.py
Original file line number Diff line number Diff line change
Expand Up @@ -576,6 +576,17 @@ def click_config_file_provider(ctx: Context, opts: CustomOptionClass, value: Non
help="Override num_flex_logs_retention_days on logs indexes where the field is present.",
cls=CustomOptionClass,
),
option(
"--skip-monitors-with-restricted-roles",
required=False,
is_flag=True,
default=False,
show_default=True,
help="Filter out monitors whose source payload has a non-empty restricted_roles list. "
"This is an explicit access-control escape hatch for DDR destinations where "
"role/user activation is not ready yet; filtered monitors are not created or updated.",
cls=CustomOptionClass,
),
option(
"--create-global-downtime",
required=False,
Expand Down
16 changes: 16 additions & 0 deletions datadog_sync/model/monitors.py
Original file line number Diff line number Diff line change
Expand Up @@ -239,6 +239,22 @@ async def delete_resource(self, _id: str) -> None:
params={"force": "true"},
)

def filter(self, resource: Dict) -> bool:
if not super().filter(resource):
return False

if getattr(self.config, "skip_monitors_with_restricted_roles", False) is True and resource.get(
"restricted_roles"
):
self.config.logger.info(
"filtering monitor with restricted_roles because --skip-monitors-with-restricted-roles is enabled",
resource_type=self.resource_type,
_id=str(resource.get("id", "")),
)
return False

return True

def connect_resources(self, _id: str, resource: Dict) -> ResourceConnectionResult:
"""Drop-aware override.

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 @@ -126,6 +126,7 @@ class Configuration(object):
prune_force: bool = False
prune_dry_run: bool = False
destination_logs_intake_url: Optional[str] = None
skip_monitors_with_restricted_roles: bool = False

async def init_async(self, cmd: Command):
await self.source_client._init_session()
Expand Down Expand Up @@ -510,6 +511,7 @@ def build_config(cmd: Command, **kwargs: Optional[Any]) -> Configuration:
skip_failed_resource_connections = kwargs.get("skip_failed_resource_connections")
drop_unresolvable_principals = kwargs.get("drop_unresolvable_principals") or False
refresh_destination_state_before_apply = kwargs.get("refresh_destination_state_before_apply") or False
skip_monitors_with_restricted_roles = kwargs.get("skip_monitors_with_restricted_roles") or False
max_workers = kwargs.get("max_workers")
max_workers_per_type_raw = kwargs.get("max_workers_per_type")
# Parse --max-workers-per-type early so malformed input fails BEFORE any
Expand Down Expand Up @@ -849,6 +851,7 @@ def build_config(cmd: Command, **kwargs: Optional[Any]) -> Configuration:
max_concurrent_reads=max_concurrent_reads,
transient_failure_threshold_pct=transient_failure_threshold_pct,
destination_logs_intake_url=destination_logs_intake_url,
skip_monitors_with_restricted_roles=skip_monitors_with_restricted_roles,
)

# Initialize resource classes
Expand Down
47 changes: 47 additions & 0 deletions tests/unit/test_monitors.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
from unittest.mock import MagicMock

from datadog_sync.model.monitors import Monitors
from datadog_sync.utils.filter import process_filters
from datadog_sync.utils.resource_utils import SkipResource, ResourceConnectionError
from datadog_sync.utils.workers import Counter

Expand Down Expand Up @@ -118,6 +119,52 @@ def test_service_check_null_options_raises_skip(self):
asyncio.run(monitors.pre_resource_action_hook("44444", resource))


class TestMonitorsFilter:
"""Filtering behavior specific to monitor access-control escape hatches."""

def _make_monitors(self, skip_restricted=False, filters=None, filter_operator="OR"):
config = MagicMock()
config.filters = filters or {}
config.filter_operator = filter_operator
config.skip_monitors_with_restricted_roles = skip_restricted
config.logger = MagicMock()
return Monitors(config)

def test_restricted_roles_allowed_by_default(self):
monitors = self._make_monitors()
resource = {"id": 269290576, "restricted_roles": ["role-src"]}

assert monitors.filter(resource) is True

def test_restricted_roles_filtered_when_flag_enabled(self):
monitors = self._make_monitors(skip_restricted=True)
resource = {"id": 269290576, "restricted_roles": ["role-src"]}

assert monitors.filter(resource) is False
monitors.config.logger.info.assert_called_once()

@pytest.mark.parametrize(
"resource",
[
{"id": 1},
{"id": 2, "restricted_roles": []},
{"id": 3, "restricted_roles": None},
],
)
def test_empty_or_missing_restricted_roles_are_not_filtered(self, resource):
monitors = self._make_monitors(skip_restricted=True)

assert monitors.filter(resource) is True

def test_existing_filters_still_apply_before_restricted_role_skip(self):
filters = process_filters(["Type=monitors;Name=id;Value=123;Operator=ExactMatch"])
monitors = self._make_monitors(skip_restricted=True, filters=filters)
resource = {"id": 456, "restricted_roles": ["role-src"]}

assert monitors.filter(resource) is False
monitors.config.logger.info.assert_not_called()


class TestMonitorsSchemaMigrations:
"""Schema migrations that adapt us1-accepted payloads to us3-required shapes."""

Expand Down
Loading