Skip to content

Commit 4e8844b

Browse files
committed
feat(gooddata-sdk): [AUTO] Add IP allowlist policy CRUD endpoints to metadata API
1 parent 38b0798 commit 4e8844b

6 files changed

Lines changed: 388 additions & 1 deletion

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: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
# (C) 2026 GoodData Corporation
2+
from __future__ import annotations
3+
4+
from typing import Any
5+
6+
import attrs
7+
8+
from gooddata_sdk.catalog.base import Base
9+
from gooddata_sdk.catalog.identifier import CatalogAssigneeIdentifier
10+
11+
12+
@attrs.define(kw_only=True)
13+
class CatalogIpAllowlistPolicy(Base):
14+
"""Represents an IP allowlist policy entity.
15+
16+
Attributes:
17+
id: Unique identifier for the policy.
18+
allowed_sources: IP addresses or CIDR ranges that are allowed by this policy.
19+
users: Users this policy applies to.
20+
user_groups: User groups this policy applies to.
21+
"""
22+
23+
id: str
24+
allowed_sources: list[str] = attrs.field(factory=list)
25+
users: list[CatalogAssigneeIdentifier] = attrs.field(factory=list)
26+
user_groups: list[CatalogAssigneeIdentifier] = attrs.field(factory=list)
27+
28+
@classmethod
29+
def from_api(cls, entity: dict[str, Any]) -> CatalogIpAllowlistPolicy:
30+
attrs_data = entity.get("attributes") or {}
31+
relationships = entity.get("relationships") or {}
32+
users_rel = (relationships.get("users") or {}).get("data") or []
33+
groups_rel = (relationships.get("userGroups") or {}).get("data") or []
34+
return cls(
35+
id=entity["id"],
36+
allowed_sources=list(attrs_data.get("allowedSources") or []),
37+
users=[CatalogAssigneeIdentifier(id=u["id"], type=u["type"]) for u in users_rel],
38+
user_groups=[CatalogAssigneeIdentifier(id=g["id"], type=g["type"]) for g in groups_rel],
39+
)
40+
41+
42+
@attrs.define(kw_only=True)
43+
class CatalogIpAllowlistPolicyTargets(Base):
44+
"""Target payload for ``addTargets`` / ``removeTargets`` action endpoints.
45+
46+
Attributes:
47+
targets: List of assignee identifiers (users or user-groups) to add or remove.
48+
"""
49+
50+
targets: list[CatalogAssigneeIdentifier] = attrs.field(factory=list)

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

Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,9 @@
22
from __future__ import annotations
33

44
import functools
5+
import json
56
from typing import Any, Literal
7+
from urllib.parse import urlparse
68

79
from gooddata_api_client.exceptions import NotFoundException
810
from gooddata_api_client.model.declarative_export_templates import DeclarativeExportTemplates
@@ -22,6 +24,10 @@
2224
from gooddata_sdk.catalog.catalog_service_base import CatalogServiceBase
2325
from gooddata_sdk.catalog.organization.entity_model.directive import CatalogCspDirective
2426
from gooddata_sdk.catalog.organization.entity_model.identity_provider import CatalogIdentityProvider
27+
from gooddata_sdk.catalog.organization.entity_model.ip_allowlist_policy import (
28+
CatalogIpAllowlistPolicy,
29+
CatalogIpAllowlistPolicyTargets,
30+
)
2531
from gooddata_sdk.catalog.organization.entity_model.jwk import CatalogJwk, CatalogJwkDocument
2632
from gooddata_sdk.catalog.organization.entity_model.llm_provider import (
2733
CatalogLlmProvider,
@@ -45,6 +51,10 @@
4551
HLL_TYPE_SETTING_ID = "hyperLogLogType"
4652
HLL_TYPE_SETTING_TYPE = "HLL_TYPE"
4753

54+
_IP_ALLOWLIST_ENTITIES_BASE = "api/v1/entities/ipAllowlistPolicies"
55+
_IP_ALLOWLIST_ACTIONS_BASE = "api/v1/actions/ipAllowlistPolicies"
56+
_JSON_CONTENT_TYPE = "application/json"
57+
4858

4959
class CatalogOrganizationService(CatalogServiceBase):
5060
def __init__(self, api_client: GoodDataApiClient) -> None:
@@ -628,6 +638,150 @@ def delete_llm_provider(self, id: str) -> None:
628638
"""
629639
self._entities_api.delete_entity_llm_providers(id, _check_return_type=False)
630640

641+
# IP Allowlist Policy APIs
642+
643+
@staticmethod
644+
def _ip_policy_to_body(policy: CatalogIpAllowlistPolicy) -> bytes:
645+
"""Serialize a policy to a JSON:API request body."""
646+
data: dict[str, Any] = {
647+
"type": "ipAllowlistPolicy",
648+
"id": policy.id,
649+
"attributes": {"allowedSources": policy.allowed_sources},
650+
}
651+
rels: dict[str, Any] = {}
652+
if policy.users:
653+
rels["users"] = {"data": [{"id": u.id, "type": u.type} for u in policy.users]}
654+
if policy.user_groups:
655+
rels["userGroups"] = {"data": [{"id": g.id, "type": g.type} for g in policy.user_groups]}
656+
if rels:
657+
data["relationships"] = rels
658+
return json.dumps({"data": data}).encode("utf-8")
659+
660+
def get_ip_allowlist_policy(self, policy_id: str) -> CatalogIpAllowlistPolicy:
661+
"""Get an IP allowlist policy by ID.
662+
663+
Args:
664+
policy_id (str): Policy identifier.
665+
666+
Returns:
667+
CatalogIpAllowlistPolicy: Retrieved policy.
668+
669+
Raises:
670+
NotFoundException: Policy does not exist.
671+
"""
672+
response = self._client._do_get_request(f"{_IP_ALLOWLIST_ENTITIES_BASE}/{policy_id}")
673+
if response.status_code == 404:
674+
raise NotFoundException(status=404, reason=response.reason)
675+
response.raise_for_status()
676+
return CatalogIpAllowlistPolicy.from_api(response.json()["data"])
677+
678+
def list_ip_allowlist_policies(self) -> list[CatalogIpAllowlistPolicy]:
679+
"""Return all IP allowlist policies in the organization.
680+
681+
Follows JSON:API ``links.next`` pagination transparently.
682+
683+
Returns:
684+
list[CatalogIpAllowlistPolicy]: All policies.
685+
"""
686+
policies: list[CatalogIpAllowlistPolicy] = []
687+
endpoint = _IP_ALLOWLIST_ENTITIES_BASE
688+
while True:
689+
response = self._client._do_get_request(endpoint)
690+
response.raise_for_status()
691+
body = response.json()
692+
policies.extend(CatalogIpAllowlistPolicy.from_api(item) for item in body["data"])
693+
next_url = (body.get("links") or {}).get("next")
694+
if not next_url:
695+
break
696+
parsed = urlparse(str(next_url))
697+
endpoint = parsed.path.lstrip("/")
698+
if parsed.query:
699+
endpoint = f"{endpoint}?{parsed.query}"
700+
return policies
701+
702+
def create_ip_allowlist_policy(self, policy: CatalogIpAllowlistPolicy) -> CatalogIpAllowlistPolicy:
703+
"""Create a new IP allowlist policy.
704+
705+
Args:
706+
policy (CatalogIpAllowlistPolicy): Policy to create.
707+
708+
Returns:
709+
CatalogIpAllowlistPolicy: Newly created policy as returned by the server.
710+
"""
711+
response = self._client._do_post_request(
712+
data=self._ip_policy_to_body(policy),
713+
endpoint=_IP_ALLOWLIST_ENTITIES_BASE,
714+
content_type=_JSON_CONTENT_TYPE,
715+
)
716+
response.raise_for_status()
717+
return CatalogIpAllowlistPolicy.from_api(response.json()["data"])
718+
719+
def update_ip_allowlist_policy(self, policy: CatalogIpAllowlistPolicy) -> CatalogIpAllowlistPolicy:
720+
"""Replace an existing IP allowlist policy (full PUT).
721+
722+
Args:
723+
policy (CatalogIpAllowlistPolicy): Updated policy object.
724+
725+
Returns:
726+
CatalogIpAllowlistPolicy: Policy as returned by the server after update.
727+
728+
Raises:
729+
NotFoundException: Policy does not exist.
730+
"""
731+
response = self._client._do_put_request(
732+
data=self._ip_policy_to_body(policy),
733+
endpoint=f"{_IP_ALLOWLIST_ENTITIES_BASE}/{policy.id}",
734+
content_type=_JSON_CONTENT_TYPE,
735+
)
736+
if response.status_code == 404:
737+
raise NotFoundException(status=404, reason=response.reason)
738+
response.raise_for_status()
739+
return CatalogIpAllowlistPolicy.from_api(response.json()["data"])
740+
741+
def delete_ip_allowlist_policy(self, policy_id: str) -> None:
742+
"""Delete an IP allowlist policy.
743+
744+
Args:
745+
policy_id (str): Policy identifier.
746+
747+
Raises:
748+
ValueError: Policy does not exist.
749+
"""
750+
response = self._client._do_delete_request(f"{_IP_ALLOWLIST_ENTITIES_BASE}/{policy_id}")
751+
if response.status_code == 404:
752+
raise ValueError(f"Cannot delete IP allowlist policy {policy_id!r}. This policy does not exist.")
753+
response.raise_for_status()
754+
755+
def add_targets_to_ip_allowlist_policy(self, policy_id: str, targets: CatalogIpAllowlistPolicyTargets) -> None:
756+
"""Add users or user-groups to an existing IP allowlist policy.
757+
758+
Args:
759+
policy_id (str): Policy identifier.
760+
targets (CatalogIpAllowlistPolicyTargets): Targets to add.
761+
"""
762+
body = json.dumps({"targets": [{"id": t.id, "type": t.type} for t in targets.targets]}).encode("utf-8")
763+
response = self._client._do_post_request(
764+
data=body,
765+
endpoint=f"{_IP_ALLOWLIST_ACTIONS_BASE}/{policy_id}/addTargets",
766+
content_type=_JSON_CONTENT_TYPE,
767+
)
768+
response.raise_for_status()
769+
770+
def remove_targets_from_ip_allowlist_policy(self, policy_id: str, targets: CatalogIpAllowlistPolicyTargets) -> None:
771+
"""Remove users or user-groups from an existing IP allowlist policy.
772+
773+
Args:
774+
policy_id (str): Policy identifier.
775+
targets (CatalogIpAllowlistPolicyTargets): Targets to remove.
776+
"""
777+
body = json.dumps({"targets": [{"id": t.id, "type": t.type} for t in targets.targets]}).encode("utf-8")
778+
response = self._client._do_post_request(
779+
data=body,
780+
endpoint=f"{_IP_ALLOWLIST_ACTIONS_BASE}/{policy_id}/removeTargets",
781+
content_type=_JSON_CONTENT_TYPE,
782+
)
783+
response.raise_for_status()
784+
631785
# Layout APIs
632786

633787
def get_declarative_notification_channels(self) -> list[CatalogDeclarativeNotificationChannel]:

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

Lines changed: 67 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -105,7 +105,7 @@ def _do_post_request(
105105
content_type (str): The content type of the data being sent.
106106
107107
Returns:
108-
None
108+
requests.Response
109109
"""
110110
if not self._hostname.endswith("/"):
111111
endpoint = f"/{endpoint}"
@@ -121,6 +121,72 @@ def _do_post_request(
121121

122122
return response
123123

124+
def _do_get_request(self, endpoint: str) -> requests.Response:
125+
"""Perform a GET request to a specified endpoint.
126+
127+
Args:
128+
endpoint (str): The endpoint path (without leading slash) to call.
129+
130+
Returns:
131+
requests.Response
132+
"""
133+
if not self._hostname.endswith("/"):
134+
endpoint = f"/{endpoint}"
135+
136+
return requests.get(
137+
url=f"{self._hostname}{endpoint}",
138+
headers={
139+
"Authorization": f"Bearer {self._token}",
140+
},
141+
)
142+
143+
def _do_put_request(
144+
self,
145+
data: bytes,
146+
endpoint: str,
147+
content_type: str,
148+
) -> requests.Response:
149+
"""Perform a PUT request to a specified endpoint.
150+
151+
Args:
152+
data (bytes): The data to be sent in the PUT request.
153+
endpoint (str): The endpoint path (without leading slash) to call.
154+
content_type (str): The content type of the data being sent.
155+
156+
Returns:
157+
requests.Response
158+
"""
159+
if not self._hostname.endswith("/"):
160+
endpoint = f"/{endpoint}"
161+
162+
return requests.put(
163+
url=f"{self._hostname}{endpoint}",
164+
headers={
165+
"Content-Type": content_type,
166+
"Authorization": f"Bearer {self._token}",
167+
},
168+
data=data,
169+
)
170+
171+
def _do_delete_request(self, endpoint: str) -> requests.Response:
172+
"""Perform a DELETE request to a specified endpoint.
173+
174+
Args:
175+
endpoint (str): The endpoint path (without leading slash) to call.
176+
177+
Returns:
178+
requests.Response
179+
"""
180+
if not self._hostname.endswith("/"):
181+
endpoint = f"/{endpoint}"
182+
183+
return requests.delete(
184+
url=f"{self._hostname}{endpoint}",
185+
headers={
186+
"Authorization": f"Bearer {self._token}",
187+
},
188+
)
189+
124190
def do_request(
125191
self,
126192
data: bytes,

packages/gooddata-sdk/tests/catalog/test_catalog_organization.py

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
from gooddata_sdk import (
88
CatalogCspDirective,
99
CatalogDeclarativeNotificationChannel,
10+
CatalogIpAllowlistPolicy,
1011
CatalogJwk,
1112
CatalogOrganization,
1213
CatalogOrganizationSetting,
@@ -563,3 +564,39 @@ def test_layout_notification_channels(test_config, snapshot_notification_channel
563564
# sdk.catalog_organization.put_declarative_identity_providers([])
564565
# idps = sdk.catalog_organization.get_declarative_identity_providers()
565566
# assert len(idps) == 0
567+
568+
569+
@gd_vcr.use_cassette(str(_fixtures_dir / "ip_allowlist_policy_crud.yaml"))
570+
def test_ip_allowlist_policy_crud(test_config):
571+
"""Integration test covering the full IP allowlist policy CRUD lifecycle."""
572+
sdk = GoodDataSdk.create(host_=test_config["host"], token_=test_config["token"])
573+
policy_id = "testIpPolicy"
574+
policy = CatalogIpAllowlistPolicy(
575+
id=policy_id,
576+
allowed_sources=["192.168.0.0/16"],
577+
)
578+
updated_policy = CatalogIpAllowlistPolicy(
579+
id=policy_id,
580+
allowed_sources=["10.0.0.0/8", "192.168.0.0/16"],
581+
)
582+
try:
583+
# Create
584+
created = sdk.catalog_organization.create_ip_allowlist_policy(policy)
585+
assert created.id == policy_id
586+
assert created.allowed_sources == ["192.168.0.0/16"]
587+
588+
# Get
589+
fetched = sdk.catalog_organization.get_ip_allowlist_policy(policy_id)
590+
assert fetched.id == policy_id
591+
assert fetched.allowed_sources == ["192.168.0.0/16"]
592+
593+
# List — policy must appear in the full list
594+
policies = sdk.catalog_organization.list_ip_allowlist_policies()
595+
assert any(p.id == policy_id for p in policies)
596+
597+
# Update
598+
updated = sdk.catalog_organization.update_ip_allowlist_policy(updated_policy)
599+
assert updated.id == policy_id
600+
assert set(updated.allowed_sources) == {"10.0.0.0/8", "192.168.0.0/16"}
601+
finally:
602+
safe_delete(sdk.catalog_organization.delete_ip_allowlist_policy, policy_id)

0 commit comments

Comments
 (0)