Skip to content

Commit 0544cac

Browse files
feat: add support for IP allowlist policies
jira: STL-2872 risk: low
1 parent c6cdda3 commit 0544cac

4 files changed

Lines changed: 315 additions & 0 deletions

File tree

packages/gooddata-sdk/src/gooddata_sdk/__init__.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,10 @@
109109
CatalogExportTemplate,
110110
CatalogExportTemplateAttributes,
111111
)
112+
from gooddata_sdk.catalog.organization.entity_model.ip_allowlist_policy import (
113+
CatalogIpAllowlistPolicy,
114+
CatalogIpAllowlistPolicyTargets,
115+
)
112116
from gooddata_sdk.catalog.organization.entity_model.jwk import (
113117
CatalogJwk,
114118
CatalogJwkAttributes,
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
# (C) 2026 GoodData Corporation
2+
from __future__ import annotations
3+
4+
from typing import Any
5+
6+
from attrs import define, field
7+
from gooddata_api_client.model.assignee_identifier import AssigneeIdentifier
8+
from gooddata_api_client.model.ip_allowlist_policy_targets import IpAllowlistPolicyTargets
9+
from gooddata_api_client.model.json_api_agent_in_relationships_user_groups import JsonApiAgentInRelationshipsUserGroups
10+
from gooddata_api_client.model.json_api_automation_in_relationships_recipients import (
11+
JsonApiAutomationInRelationshipsRecipients,
12+
)
13+
from gooddata_api_client.model.json_api_ip_allowlist_policy_in import JsonApiIpAllowlistPolicyIn
14+
from gooddata_api_client.model.json_api_ip_allowlist_policy_in_attributes import JsonApiIpAllowlistPolicyInAttributes
15+
from gooddata_api_client.model.json_api_ip_allowlist_policy_in_document import JsonApiIpAllowlistPolicyInDocument
16+
from gooddata_api_client.model.json_api_ip_allowlist_policy_in_relationships import (
17+
JsonApiIpAllowlistPolicyInRelationships,
18+
)
19+
from gooddata_api_client.model.json_api_user_group_linkage import JsonApiUserGroupLinkage
20+
from gooddata_api_client.model.json_api_user_group_to_many_linkage import JsonApiUserGroupToManyLinkage
21+
from gooddata_api_client.model.json_api_user_linkage import JsonApiUserLinkage
22+
from gooddata_api_client.model.json_api_user_to_many_linkage import JsonApiUserToManyLinkage
23+
24+
from gooddata_sdk.catalog.base import Base
25+
from gooddata_sdk.catalog.identifier import CatalogAssigneeIdentifier
26+
27+
_POLICY_TYPE = "ipAllowlistPolicy"
28+
29+
30+
@define(kw_only=True)
31+
class CatalogIpAllowlistPolicy(Base):
32+
"""Represents an IP allowlist policy entity."""
33+
34+
id: str
35+
allowed_sources: list[str] = field(factory=list)
36+
users: list[CatalogAssigneeIdentifier] = field(factory=list)
37+
user_groups: list[CatalogAssigneeIdentifier] = field(factory=list)
38+
39+
@staticmethod
40+
def client_class() -> type[JsonApiIpAllowlistPolicyIn]:
41+
return JsonApiIpAllowlistPolicyIn
42+
43+
@classmethod
44+
def from_api(cls, entity: dict[str, Any]) -> CatalogIpAllowlistPolicy:
45+
attributes = entity.get("attributes") or {}
46+
relationships = entity.get("relationships") or {}
47+
users = (relationships.get("users") or {}).get("data") or []
48+
user_groups = (relationships.get("userGroups") or {}).get("data") or []
49+
return cls(
50+
id=entity["id"],
51+
allowed_sources=list(attributes.get("allowedSources") or []),
52+
users=[CatalogAssigneeIdentifier(id=user["id"], type=user["type"]) for user in users],
53+
user_groups=[CatalogAssigneeIdentifier(id=group["id"], type=group["type"]) for group in user_groups],
54+
)
55+
56+
def to_api(self) -> JsonApiIpAllowlistPolicyInDocument:
57+
relationships: dict[str, Any] = {}
58+
if self.users:
59+
relationships["users"] = JsonApiAutomationInRelationshipsRecipients(
60+
data=JsonApiUserToManyLinkage([JsonApiUserLinkage(id=user.id, type=user.type) for user in self.users])
61+
)
62+
if self.user_groups:
63+
relationships["user_groups"] = JsonApiAgentInRelationshipsUserGroups(
64+
data=JsonApiUserGroupToManyLinkage(
65+
[JsonApiUserGroupLinkage(id=group.id, type=group.type) for group in self.user_groups]
66+
)
67+
)
68+
data_kwargs: dict[str, Any] = {
69+
"id": self.id,
70+
"type": _POLICY_TYPE,
71+
"attributes": JsonApiIpAllowlistPolicyInAttributes(allowed_sources=self.allowed_sources),
72+
}
73+
if relationships:
74+
data_kwargs["relationships"] = JsonApiIpAllowlistPolicyInRelationships(**relationships)
75+
return JsonApiIpAllowlistPolicyInDocument(data=JsonApiIpAllowlistPolicyIn(**data_kwargs))
76+
77+
78+
@define(kw_only=True)
79+
class CatalogIpAllowlistPolicyTargets(Base):
80+
"""Target payload for IP allowlist policy add/remove target actions."""
81+
82+
targets: list[CatalogAssigneeIdentifier] = field(factory=list)
83+
84+
@staticmethod
85+
def client_class() -> type[IpAllowlistPolicyTargets]:
86+
return IpAllowlistPolicyTargets
87+
88+
def to_api(self) -> IpAllowlistPolicyTargets:
89+
return IpAllowlistPolicyTargets(
90+
targets=[AssigneeIdentifier(id=target.id, type=target.type) for target in self.targets]
91+
)

packages/gooddata-sdk/src/gooddata_sdk/catalog/organization/service.py

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,10 @@
2525
from gooddata_sdk.catalog.catalog_service_base import CatalogServiceBase
2626
from gooddata_sdk.catalog.organization.entity_model.directive import CatalogCspDirective
2727
from gooddata_sdk.catalog.organization.entity_model.identity_provider import CatalogIdentityProvider
28+
from gooddata_sdk.catalog.organization.entity_model.ip_allowlist_policy import (
29+
CatalogIpAllowlistPolicy,
30+
CatalogIpAllowlistPolicyTargets,
31+
)
2832
from gooddata_sdk.catalog.organization.entity_model.jwk import CatalogJwk, CatalogJwkDocument
2933
from gooddata_sdk.catalog.organization.entity_model.llm_provider import (
3034
CatalogLlmProvider,
@@ -706,6 +710,62 @@ def list_llm_provider_models_by_id(self, id: str) -> CatalogLlmProviderModelsRes
706710
response = self._actions_api.list_llm_provider_models_by_id(id)
707711
return CatalogLlmProviderModelsResult.from_api(response)
708712

713+
# IP Allowlist Policy APIs
714+
715+
def get_ip_allowlist_policy(self, policy_id: str) -> CatalogIpAllowlistPolicy:
716+
"""Get an IP allowlist policy by ID.
717+
718+
Args:
719+
policy_id: IP allowlist policy identifier.
720+
721+
Returns:
722+
CatalogIpAllowlistPolicy: Retrieved policy.
723+
"""
724+
response = self._entities_api.get_entity_ip_allowlist_policies(policy_id, _check_return_type=False)
725+
return CatalogIpAllowlistPolicy.from_api(response.data)
726+
727+
def list_ip_allowlist_policies(self) -> list[CatalogIpAllowlistPolicy]:
728+
"""Return all IP allowlist policies in the organization."""
729+
get_policies = functools.partial(
730+
self._entities_api.get_all_entities_ip_allowlist_policies, _check_return_type=False
731+
)
732+
policies = load_all_entities(get_policies)
733+
return [CatalogIpAllowlistPolicy.from_api(policy) for policy in policies.data]
734+
735+
def create_ip_allowlist_policy(self, policy: CatalogIpAllowlistPolicy) -> CatalogIpAllowlistPolicy:
736+
"""Create a new IP allowlist policy."""
737+
response = self._entities_api.create_entity_ip_allowlist_policies(
738+
json_api_ip_allowlist_policy_in_document=policy.to_api(), _check_return_type=False
739+
)
740+
return CatalogIpAllowlistPolicy.from_api(response.data)
741+
742+
def update_ip_allowlist_policy(self, policy: CatalogIpAllowlistPolicy) -> CatalogIpAllowlistPolicy:
743+
"""Replace an existing IP allowlist policy."""
744+
response = self._entities_api.update_entity_ip_allowlist_policies(
745+
policy.id, policy.to_api(), _check_return_type=False
746+
)
747+
return CatalogIpAllowlistPolicy.from_api(response.data)
748+
749+
def delete_ip_allowlist_policy(self, policy_id: str) -> None:
750+
"""Delete an IP allowlist policy."""
751+
self._entities_api.delete_entity_ip_allowlist_policies(policy_id, _check_return_type=False)
752+
753+
def add_targets_to_ip_allowlist_policy(
754+
self,
755+
policy_id: str,
756+
targets: CatalogIpAllowlistPolicyTargets,
757+
) -> None:
758+
"""Add users or user groups to an IP allowlist policy."""
759+
self._actions_api.add_targets(policy_id, targets.to_api())
760+
761+
def remove_targets_from_ip_allowlist_policy(
762+
self,
763+
policy_id: str,
764+
targets: CatalogIpAllowlistPolicyTargets,
765+
) -> None:
766+
"""Remove users or user groups from an IP allowlist policy."""
767+
self._actions_api.remove_targets(policy_id, targets.to_api())
768+
709769
# Layout APIs
710770

711771
def get_declarative_notification_channels(self) -> list[CatalogDeclarativeNotificationChannel]:
Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
1+
# (C) 2026 GoodData Corporation
2+
from __future__ import annotations
3+
4+
from types import SimpleNamespace
5+
from unittest.mock import MagicMock
6+
7+
from gooddata_sdk.catalog.identifier import CatalogAssigneeIdentifier
8+
from gooddata_sdk.catalog.organization.entity_model.ip_allowlist_policy import (
9+
CatalogIpAllowlistPolicy,
10+
CatalogIpAllowlistPolicyTargets,
11+
)
12+
from gooddata_sdk.catalog.organization.service import CatalogOrganizationService
13+
14+
15+
def _make_service() -> tuple[CatalogOrganizationService, MagicMock, MagicMock]:
16+
"""Build a service whose entities-api and actions-api sides are fully mocked."""
17+
fake_entities_api = MagicMock(name="EntitiesApi")
18+
fake_actions_api = MagicMock(name="ActionsApi")
19+
fake_client = SimpleNamespace(
20+
entities_api=fake_entities_api,
21+
layout_api=MagicMock(name="LayoutApi"),
22+
actions_api=fake_actions_api,
23+
user_management_api=MagicMock(name="UserManagementApi"),
24+
)
25+
service = CatalogOrganizationService(fake_client) # type: ignore[arg-type]
26+
return service, fake_entities_api, fake_actions_api
27+
28+
29+
def test_ip_allowlist_policy_from_api_reads_attributes_and_relationships() -> None:
30+
policy = CatalogIpAllowlistPolicy.from_api(
31+
{
32+
"id": "corp-vpn-only",
33+
"type": "ipAllowlistPolicy",
34+
"attributes": {"allowedSources": ["203.0.113.10/32", "198.51.100.0/24"]},
35+
"relationships": {
36+
"users": {"data": [{"id": "admin", "type": "user"}]},
37+
"userGroups": {"data": [{"id": "admins", "type": "userGroup"}]},
38+
},
39+
}
40+
)
41+
42+
assert policy.id == "corp-vpn-only"
43+
assert policy.allowed_sources == ["203.0.113.10/32", "198.51.100.0/24"]
44+
assert policy.users == [CatalogAssigneeIdentifier(id="admin", type="user")]
45+
assert policy.user_groups == [CatalogAssigneeIdentifier(id="admins", type="userGroup")]
46+
47+
48+
def test_ip_allowlist_policy_from_api_defaults_missing_optional_fields() -> None:
49+
policy = CatalogIpAllowlistPolicy.from_api({"id": "corp-vpn-only", "type": "ipAllowlistPolicy"})
50+
51+
assert policy.id == "corp-vpn-only"
52+
assert policy.allowed_sources == []
53+
assert policy.users == []
54+
assert policy.user_groups == []
55+
56+
57+
def test_ip_allowlist_policy_to_api_uses_json_api_shape() -> None:
58+
policy = CatalogIpAllowlistPolicy(
59+
id="corp-vpn-only",
60+
allowed_sources=["203.0.113.10/32"],
61+
users=[CatalogAssigneeIdentifier(id="admin", type="user")],
62+
user_groups=[CatalogAssigneeIdentifier(id="admins", type="userGroup")],
63+
)
64+
65+
document = policy.to_api()
66+
data = document.data
67+
68+
assert data.id == "corp-vpn-only"
69+
assert data.type == "ipAllowlistPolicy"
70+
assert data.attributes.allowed_sources == ["203.0.113.10/32"]
71+
users = data.relationships.users.data.value
72+
assert [(user.id, user.type) for user in users] == [("admin", "user")]
73+
user_groups = data.relationships.user_groups.data.value
74+
assert [(group.id, group.type) for group in user_groups] == [("admins", "userGroup")]
75+
76+
77+
def test_ip_allowlist_policy_to_api_omits_empty_relationships() -> None:
78+
policy = CatalogIpAllowlistPolicy(id="corp-vpn-only", allowed_sources=["203.0.113.10/32"])
79+
80+
document = policy.to_api()
81+
82+
assert document.data.attributes.allowed_sources == ["203.0.113.10/32"]
83+
assert "relationships" not in document.data
84+
85+
86+
def test_ip_allowlist_targets_to_api_uses_action_payload_shape() -> None:
87+
targets = CatalogIpAllowlistPolicyTargets(
88+
targets=[
89+
CatalogAssigneeIdentifier(id="admin", type="user"),
90+
CatalogAssigneeIdentifier(id="admins", type="userGroup"),
91+
]
92+
)
93+
94+
payload = targets.to_api()
95+
96+
assert [(target.id, target.type) for target in payload.targets] == [
97+
("admin", "user"),
98+
("admins", "userGroup"),
99+
]
100+
101+
102+
def test_ip_allowlist_policy_crud_methods_call_generated_client() -> None:
103+
service, entities_api, _ = _make_service()
104+
policy_out = SimpleNamespace(
105+
data={
106+
"id": "corp-vpn-only",
107+
"type": "ipAllowlistPolicy",
108+
"attributes": {"allowedSources": ["203.0.113.10/32"]},
109+
}
110+
)
111+
entities_api.get_entity_ip_allowlist_policies.return_value = policy_out
112+
entities_api.create_entity_ip_allowlist_policies.return_value = policy_out
113+
entities_api.update_entity_ip_allowlist_policies.return_value = policy_out
114+
115+
policy = CatalogIpAllowlistPolicy(id="corp-vpn-only", allowed_sources=["203.0.113.10/32"])
116+
117+
assert service.get_ip_allowlist_policy("corp-vpn-only").id == "corp-vpn-only"
118+
assert service.create_ip_allowlist_policy(policy).allowed_sources == ["203.0.113.10/32"]
119+
assert service.update_ip_allowlist_policy(policy).id == "corp-vpn-only"
120+
service.delete_ip_allowlist_policy("corp-vpn-only")
121+
122+
assert entities_api.get_entity_ip_allowlist_policies.call_args.args[0] == "corp-vpn-only"
123+
create_doc = entities_api.create_entity_ip_allowlist_policies.call_args.kwargs[
124+
"json_api_ip_allowlist_policy_in_document"
125+
]
126+
assert create_doc.data.id == "corp-vpn-only"
127+
assert entities_api.update_entity_ip_allowlist_policies.call_args.args[0] == "corp-vpn-only"
128+
assert entities_api.delete_entity_ip_allowlist_policies.call_args.args[0] == "corp-vpn-only"
129+
130+
131+
def test_list_ip_allowlist_policies_loads_all_entities() -> None:
132+
service, entities_api, _ = _make_service()
133+
entities_api.get_all_entities_ip_allowlist_policies.return_value = SimpleNamespace(
134+
data=[
135+
{"id": "first", "type": "ipAllowlistPolicy"},
136+
{"id": "second", "type": "ipAllowlistPolicy"},
137+
],
138+
included=[],
139+
links=SimpleNamespace(next=None),
140+
)
141+
142+
policies = service.list_ip_allowlist_policies()
143+
144+
assert [policy.id for policy in policies] == ["first", "second"]
145+
assert entities_api.get_all_entities_ip_allowlist_policies.called
146+
147+
148+
def test_ip_allowlist_target_actions_call_generated_client() -> None:
149+
service, _, actions_api = _make_service()
150+
targets = CatalogIpAllowlistPolicyTargets(targets=[CatalogAssigneeIdentifier(id="admin", type="user")])
151+
152+
service.add_targets_to_ip_allowlist_policy("corp-vpn-only", targets)
153+
service.remove_targets_from_ip_allowlist_policy("corp-vpn-only", targets)
154+
155+
add_targets = actions_api.add_targets.call_args.args[1].targets
156+
assert actions_api.add_targets.call_args.args[0] == "corp-vpn-only"
157+
assert [(target.id, target.type) for target in add_targets] == [("admin", "user")]
158+
remove_targets = actions_api.remove_targets.call_args.args[1].targets
159+
assert actions_api.remove_targets.call_args.args[0] == "corp-vpn-only"
160+
assert [(target.id, target.type) for target in remove_targets] == [("admin", "user")]

0 commit comments

Comments
 (0)