From 56386328d105f3ad89d686bbc8b5d0b275b270e5 Mon Sep 17 00:00:00 2001 From: Mitch Kelly Date: Tue, 18 Oct 2022 11:31:49 +1100 Subject: [PATCH 01/54] feat: Adds initial support for ZDX --- pyzscaler/__init__.py | 1 + pyzscaler/zdx/__init__.py | 80 +++++++++++++++++++++++++++++++++++++++ pyzscaler/zdx/admin.py | 6 +++ pyzscaler/zdx/apps.py | 6 +++ pyzscaler/zdx/session.py | 40 ++++++++++++++++++++ 5 files changed, 133 insertions(+) create mode 100644 pyzscaler/zdx/__init__.py create mode 100644 pyzscaler/zdx/admin.py create mode 100644 pyzscaler/zdx/apps.py create mode 100644 pyzscaler/zdx/session.py diff --git a/pyzscaler/__init__.py b/pyzscaler/__init__.py index a318002..de5b9b6 100644 --- a/pyzscaler/__init__.py +++ b/pyzscaler/__init__.py @@ -7,5 +7,6 @@ __version__ = "1.4.0" from pyzscaler.zcc import ZCC # noqa +from pyzscaler.zdx import ZDX # noqa from pyzscaler.zia import ZIA # noqa from pyzscaler.zpa import ZPA # noqa diff --git a/pyzscaler/zdx/__init__.py b/pyzscaler/zdx/__init__.py new file mode 100644 index 0000000..9165e63 --- /dev/null +++ b/pyzscaler/zdx/__init__.py @@ -0,0 +1,80 @@ +import os + +from box import Box +from restfly.session import APISession + +from pyzscaler import __version__ + +from .admin import AdminAPI +from .apps import AppsAPI +from .session import SessionAPI + + +class ZDX(APISession): + """ + A Controller to access Endpoints in the Zscaler Digital Experience (ZDX) API. + + The ZDX object stores the session token and simplifies access to CRUD options within the ZDX Portal. + + Attributes: + client_id (str): The ZCC Client ID generated from the ZCC Portal. + client_secret (str): The ZCC Client Secret generated from the ZCC Portal. + cloud (str): The Zscaler cloud for your tenancy, accepted values are: + + * ``zscaler`` + * ``zscalerone`` + * ``zscalertwo`` + * ``zscalerthree`` + * ``zscloud`` + * ``zscalerbeta`` + company_id (str): + The ZCC Company ID. There seems to be no easy way to obtain this at present. See the note + at the top of this page for information on how to retrieve the Company ID. + override_url (str): + If supplied, this attribute can be used to override the production URL that is derived + from supplying the `cloud` attribute. Use this attribute if you have a non-standard tenant URL + (e.g. internal test instance etc). When using this attribute, there is no need to supply the `cloud` + attribute. The override URL will be prepended to the API endpoint suffixes. The protocol must be included + i.e. http:// or https://. + + """ + + _vendor = "Zscaler" + _product = "pyZscaler" + _backoff = 3 + _build = __version__ + _box = True + _box_attrs = {"camel_killer_box": True} + _env_base = "ZDX" + _env_cloud = "zdxcloud" + _url = "https://api.zdxcloud.net/v1" + + def __init__(self, **kw): + self._client_id = kw.get("client_id", os.getenv(f"{self._env_base}_CLIENT_ID")) + self._client_secret = kw.get("client_secret", os.getenv(f"{self._env_base}_CLIENT_SECRET")) + self._cloud = kw.get("cloud", os.getenv(f"{self._env_base}_CLOUD", self._env_cloud)) + self._url = kw.get("override_url", os.getenv(f"{self._env_base}_OVERRIDE_URL")) or f"https://api.{self._cloud}.net/v1" + self.conv_box = True + super(ZDX, self).__init__(**kw) + + def _build_session(self, **kwargs) -> Box: + """Creates a ZCC API session.""" + super(ZDX, self)._build_session(**kwargs) + self._auth_token = self.session.create_token(client_id=self._client_id, client_secret=self._client_secret) + return self._session.headers.update({"Authorization": f"Bearer {self._auth_token}"}) + + @property + def session(self): + """The interface object for the :ref:`ZDX Session interface `.""" + return SessionAPI(self) + + @property + def admin(self): + """The interface object for the :ref:`ZDX Admin interface `.""" + return AdminAPI(self) + + @property + def apps(self): + """The interface object for the :ref:`ZDX Apps interface `.""" + print(f"Headers are: {self._session.headers}") + return AppsAPI(self) diff --git a/pyzscaler/zdx/admin.py b/pyzscaler/zdx/admin.py new file mode 100644 index 0000000..25dba88 --- /dev/null +++ b/pyzscaler/zdx/admin.py @@ -0,0 +1,6 @@ +from restfly.endpoint import APIEndpoint + + +class AdminAPI(APIEndpoint): + def get_departments(self): + return self._get("administration/departments") diff --git a/pyzscaler/zdx/apps.py b/pyzscaler/zdx/apps.py new file mode 100644 index 0000000..efe30f0 --- /dev/null +++ b/pyzscaler/zdx/apps.py @@ -0,0 +1,6 @@ +from restfly.endpoint import APIEndpoint + + +class AppsAPI(APIEndpoint): + def get_apps(self): + return self._get("apps") diff --git a/pyzscaler/zdx/session.py b/pyzscaler/zdx/session.py new file mode 100644 index 0000000..71b732c --- /dev/null +++ b/pyzscaler/zdx/session.py @@ -0,0 +1,40 @@ +import time +from hashlib import sha256 + +from box import Box +from restfly.endpoint import APIEndpoint + + +class SessionAPI(APIEndpoint): + def create_token(self, client_id: str, client_secret: str) -> Box: + """ + Creates a ZDX authentication token. + + Args: + client_id (str): The ZDX API Key ID. + client_secret (str): The ZDX API Key Secret. + + Returns: + :obj:`Box`: The authenticated session information. + + Examples: + >>> zia.session.create(client_id='999999999', + ... client_secret='admin@example.com') + + """ + epoch_time = int(time.time()) + + # Zscaler requires the API Secret Key to be appended with the epoch timestamp, separated by a colon. We then + # need to take the SHA256 hash of this string and pass that as the API Secret Key. + api_secret_format = f"{client_secret}:{epoch_time}" + api_secret_hash = sha256(api_secret_format.encode("utf-8")).hexdigest() + + payload = {"key_id": client_id, "key_secret": api_secret_hash, "timestamp": epoch_time} + + return self._post("oauth/token", json=payload).token + + def validate_token(self): + return self._get("oauth/validate") + + def get_jwks(self): + return self._get("oauth/jwks") From 28faa719561bdfe10e4bf008fdfd788058b4c48d Mon Sep 17 00:00:00 2001 From: Mitch Kelly Date: Thu, 16 Feb 2023 21:57:56 +1100 Subject: [PATCH 02/54] feat: add additional ZDX API endpoints --- pyzscaler/zdx/__init__.py | 19 +++++-------------- pyzscaler/zdx/admin.py | 15 +++++++++++++++ pyzscaler/zdx/apps.py | 19 ++++++++++++++++++- 3 files changed, 38 insertions(+), 15 deletions(-) diff --git a/pyzscaler/zdx/__init__.py b/pyzscaler/zdx/__init__.py index 9165e63..696a2b8 100644 --- a/pyzscaler/zdx/__init__.py +++ b/pyzscaler/zdx/__init__.py @@ -4,7 +4,6 @@ from restfly.session import APISession from pyzscaler import __version__ - from .admin import AdminAPI from .apps import AppsAPI from .session import SessionAPI @@ -17,19 +16,12 @@ class ZDX(APISession): The ZDX object stores the session token and simplifies access to CRUD options within the ZDX Portal. Attributes: - client_id (str): The ZCC Client ID generated from the ZCC Portal. - client_secret (str): The ZCC Client Secret generated from the ZCC Portal. - cloud (str): The Zscaler cloud for your tenancy, accepted values are: + client_id (str): The ZDX API Client ID generated from the ZSX Portal. + client_secret (str): The ZDX API Client Secret generated from the ZDX Portal. + cloud (str): The Zscaler cloud for your tenancy, current working values are: + + * ``zdxcloud`` - * ``zscaler`` - * ``zscalerone`` - * ``zscalertwo`` - * ``zscalerthree`` - * ``zscloud`` - * ``zscalerbeta`` - company_id (str): - The ZCC Company ID. There seems to be no easy way to obtain this at present. See the note - at the top of this page for information on how to retrieve the Company ID. override_url (str): If supplied, this attribute can be used to override the production URL that is derived from supplying the `cloud` attribute. Use this attribute if you have a non-standard tenant URL @@ -76,5 +68,4 @@ def admin(self): @property def apps(self): """The interface object for the :ref:`ZDX Apps interface `.""" - print(f"Headers are: {self._session.headers}") return AppsAPI(self) diff --git a/pyzscaler/zdx/admin.py b/pyzscaler/zdx/admin.py index 25dba88..cc76884 100644 --- a/pyzscaler/zdx/admin.py +++ b/pyzscaler/zdx/admin.py @@ -3,4 +3,19 @@ class AdminAPI(APIEndpoint): def get_departments(self): + """ + Returns a list of departments that are configured within ZDX. + + Returns: + + """ return self._get("administration/departments") + + def get_locations(self): + """ + Returns a list of locations that are configured within ZDX. + + Returns: + + """ + return self._get("administration/locations") diff --git a/pyzscaler/zdx/apps.py b/pyzscaler/zdx/apps.py index efe30f0..d2e90ff 100644 --- a/pyzscaler/zdx/apps.py +++ b/pyzscaler/zdx/apps.py @@ -2,5 +2,22 @@ class AppsAPI(APIEndpoint): - def get_apps(self): + def list_apps(self): + """ + Returns a list of all active applications configured within the ZDX tenant. + + Returns: + + """ return self._get("apps") + + def get_app(self, app_id: str): + """ + Returns information on the specified application configured within the ZDX tenant. + Args: + app_id (str): The unique ID for the ZDX application. + + Returns: + + """ + return self._get(f"apps/{app_id}") From 8ac612b210ae16eaa2b667c730de730b0f512281 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=81lvaro=20Gonz=C3=A1lez?= Date: Wed, 22 Mar 2023 23:28:26 +0100 Subject: [PATCH 03/54] docs: fix zpa.session example code (#185) --- pyzscaler/zpa/session.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyzscaler/zpa/session.py b/pyzscaler/zpa/session.py index cea6921..0687768 100644 --- a/pyzscaler/zpa/session.py +++ b/pyzscaler/zpa/session.py @@ -20,7 +20,7 @@ def create_token(self, client_id: str, client_secret: str): :obj:`dict`: The authenticated session information. Examples: - >>> zpa.session.create(client_id='xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx==', + >>> zpa.session.create_token(client_id='xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx==', ... client_secret='yyyyyyyyyyyyyyyyyyyyyyyyyyyyyy') """ From b9f8af41829291a54f9f9819c8ae710a4434cca6 Mon Sep 17 00:00:00 2001 From: david-kn <14314517+david-kn@users.noreply.github.com> Date: Wed, 15 Mar 2023 05:07:15 +0100 Subject: [PATCH 04/54] feat: support ZPA policies complex conditions (#166) --- pyzscaler/zpa/policies.py | 66 +++++++++++++++++++++++++++++----- tests/zpa/test_zpa_policies.py | 50 ++++++++++++++++++++++++++ 2 files changed, 108 insertions(+), 8 deletions(-) diff --git a/pyzscaler/zpa/policies.py b/pyzscaler/zpa/policies.py index 9faac35..d0c965c 100644 --- a/pyzscaler/zpa/policies.py +++ b/pyzscaler/zpa/policies.py @@ -13,26 +13,76 @@ class PolicySetsAPI(APIEndpoint): } @staticmethod - def _create_conditions(conditions: list): + def _create_conditions(conditions: list) -> list: """ - Creates a dict template for feeding conditions into the ZPA Policies API when adding or updating a policy. + Creates a list template for feeding conditions into the ZPA Policies API when adding or updating a policy. Args: - conditions (list): List of condition tuples. + conditions (list): List of condition tuples or lists (containing more complex logic statements). Returns: - :obj:`dict`: The conditions template. + :obj:`list`: The conditions template. """ - template = [] - + final_template = [] for condition in conditions: + if isinstance(condition, list): + template, operator = PolicySetsAPI._parse_condition(condition) + + if operator: + expression = {"operands": template, "operator": operator.upper()} + else: + expression = {"operands": template} + final_template.append(expression) + + # for backward compatibility: if isinstance(condition, tuple) and len(condition) == 3: - operand = {"operands": [{"objectType": condition[0].upper(), "lhs": condition[1], "rhs": condition[2]}]} + template = PolicySetsAPI._format_tuple_condition(condition) + final_template.append({"operands": [template]}) + + return final_template + + @staticmethod + def _format_tuple_condition(condition: tuple): + """Formats a simple tuple condition + + Args: + condition (tuple): A condition tuple + + Returns: + dict[str, str]: Formatted dict structure for ZIA Policies API + """ + return { + "objectType": condition[0].upper(), + "lhs": condition[1], + "rhs": condition[2], + } + + @staticmethod + def _parse_condition(condition: list): + """ + Transforms a single statement with operand into a format for a condition template. + + Args: + conditions (list): A single list of condition statement + + Returns: + :obj:`list`: The conditions template. + :obj:`string`: The type of operator within conditon (AND | OR) + + """ + template = [] + operator = 0 + + for parameter in condition: + if isinstance(parameter, str) and (parameter.upper() == "OR" or parameter.upper() == "AND"): + operator = parameter + if isinstance(parameter, tuple) and len(parameter) == 3: + operand = PolicySetsAPI._format_tuple_condition(parameter) template.append(operand) - return template + return template, operator def get_policy(self, policy_type: str) -> Box: """ diff --git a/tests/zpa/test_zpa_policies.py b/tests/zpa/test_zpa_policies.py index f05f2ba..d728ef7 100644 --- a/tests/zpa/test_zpa_policies.py +++ b/tests/zpa/test_zpa_policies.py @@ -13,6 +13,28 @@ def fixture_policies(): return {"totalPages": 1, "list": [{"id": "1"}, {"id": "2"}, {"id": "3"}]} +@pytest.fixture(name="policy_conditions") +def fixture_policy_conditions(): + return [ + [ + ("app", "id", "216197915188658453"), + ("app", "id", "216197915188658455"), + "OR", + ], + [ + ("scim", "216197915188658304", "john.doe@foo.bar"), + ("scim", "216197915188658304", "foo.bar"), + "OR", + ], + ("scim_group", "216197915188658303", "241874"), # check backward compatibility + [ + ("posture", "fc92ead2-4046-428d-bf3f-6e534a53194b", "TRUE"), + ("posture", "490db9b4-96d8-4035-9b5e-935daa697f45", "TRUE"), + "AND", + ], + ] + + @pytest.fixture(name="policy_rules") def fixture_policy_rules(): return { @@ -116,6 +138,34 @@ def test_list_policy_rules_error(zpa, policy_rules): resp = zpa.policies.list_rules("test") +def test_create_conditions(zpa, policy_conditions): + conditions = zpa.policies._create_conditions(policy_conditions) + assert conditions == [ + { + "operands": [ + {"objectType": "APP", "lhs": "id", "rhs": "216197915188658453"}, + {"objectType": "APP", "lhs": "id", "rhs": "216197915188658455"}, + ], + "operator": "OR", + }, + { + "operands": [ + {"objectType": "SCIM", "lhs": "216197915188658304", "rhs": "john.doe@foo.bar"}, + {"objectType": "SCIM", "lhs": "216197915188658304", "rhs": "foo.bar"}, + ], + "operator": "OR", + }, + {"operands": [{"objectType": "SCIM_GROUP", "lhs": "216197915188658303", "rhs": "241874"}]}, + { + "operands": [ + {"objectType": "POSTURE", "lhs": "fc92ead2-4046-428d-bf3f-6e534a53194b", "rhs": "TRUE"}, + {"objectType": "POSTURE", "lhs": "490db9b4-96d8-4035-9b5e-935daa697f45", "rhs": "TRUE"}, + ], + "operator": "AND", + }, + ] + + @responses.activate def test_get_access_policy(zpa, policies): responses.add( From 195ec6ea4c7c205d6aae28d7f9b59c7a8fdf0093 Mon Sep 17 00:00:00 2001 From: Mitch Kelly Date: Thu, 23 Mar 2023 16:08:04 +1100 Subject: [PATCH 05/54] refactor: convert policy template methods from static to class methods. optimise functions. --- pyzscaler/zpa/policies.py | 86 +++++++++++++++++++-------------------- 1 file changed, 41 insertions(+), 45 deletions(-) diff --git a/pyzscaler/zpa/policies.py b/pyzscaler/zpa/policies.py index d0c965c..d8f5136 100644 --- a/pyzscaler/zpa/policies.py +++ b/pyzscaler/zpa/policies.py @@ -12,8 +12,7 @@ class PolicySetsAPI(APIEndpoint): "siem": "SIEM_POLICY", } - @staticmethod - def _create_conditions(conditions: list) -> list: + def _create_conditions(self, conditions: list) -> list: """ Creates a list template for feeding conditions into the ZPA Policies API when adding or updating a policy. @@ -21,68 +20,61 @@ def _create_conditions(conditions: list) -> list: conditions (list): List of condition tuples or lists (containing more complex logic statements). Returns: - :obj:`list`: The conditions template. + list: The conditions template. """ - final_template = [] for condition in conditions: - if isinstance(condition, list): - template, operator = PolicySetsAPI._parse_condition(condition) - - if operator: - expression = {"operands": template, "operator": operator.upper()} - else: - expression = {"operands": template} - final_template.append(expression) - - # for backward compatibility: - if isinstance(condition, tuple) and len(condition) == 3: - template = PolicySetsAPI._format_tuple_condition(condition) - final_template.append({"operands": [template]}) - + template, operator = self._parse_condition(condition) + expression = {"operands": template} + if operator: + expression["operator"] = operator.upper() + final_template.append(expression) return final_template - @staticmethod - def _format_tuple_condition(condition: tuple): - """Formats a simple tuple condition + def _parse_condition(self, condition): + """ + Transforms a single statement with operand into a format for a condition template. Args: - condition (tuple): A condition tuple + condition: A single list of condition statements or a tuple. Returns: - dict[str, str]: Formatted dict structure for ZIA Policies API + tuple: A tuple containing the formatted condition template (list) and the operator (str, "AND" or "OR") + or None. + """ - return { - "objectType": condition[0].upper(), - "lhs": condition[1], - "rhs": condition[2], - } + # If this is a tuple condition on its own, process it now + if isinstance(condition, tuple) and len(condition) == 3: + return [self._format_condition_tuple(condition)], None + + # Otherwise we'd expect a list of tuples and an optional operator at the end + template = [] + operator = None + for parameter in condition: + if isinstance(parameter, str) and parameter.upper() in ["OR", "AND"]: + operator = parameter + elif isinstance(parameter, tuple) and len(parameter) == 3: + template.append(self._format_condition_tuple(parameter)) + return template, operator @staticmethod - def _parse_condition(condition: list): + def _format_condition_tuple(condition: tuple): """ - Transforms a single statement with operand into a format for a condition template. + Formats a simple tuple condition. Args: - conditions (list): A single list of condition statement + condition (tuple): A condition tuple (objectType, lhs, rhs). Returns: - :obj:`list`: The conditions template. - :obj:`string`: The type of operator within conditon (AND | OR) + dict: Formatted dict structure for ZIA Policies API. """ - template = [] - operator = 0 - - for parameter in condition: - if isinstance(parameter, str) and (parameter.upper() == "OR" or parameter.upper() == "AND"): - operator = parameter - if isinstance(parameter, tuple) and len(parameter) == 3: - operand = PolicySetsAPI._format_tuple_condition(parameter) - template.append(operand) - - return template, operator + return { + "objectType": condition[0].upper(), + "lhs": condition[1], + "rhs": condition[2], + } def get_policy(self, policy_type: str) -> Box: """ @@ -248,7 +240,11 @@ def add_access_rule(self, name: str, action: str, **kwargs) -> Box: """ # Initialise the payload - payload = {"name": name, "action": action.upper(), "conditions": self._create_conditions(kwargs.pop("conditions", []))} + payload = { + "name": name, + "action": action.upper(), + "conditions": self._create_conditions(kwargs.pop("conditions", [])), + } # Get the policy id of the provided policy type for the URL. policy_id = self.get_policy("access").id From 584fe2ff1050c5dade145806bc9640198e098965 Mon Sep 17 00:00:00 2001 From: Mitch Kelly Date: Tue, 4 Apr 2023 05:48:01 +1000 Subject: [PATCH 06/54] fix: fixes an issue where python black doesn't run correctly Signed-off-by: Mitch Kelly --- .pre-commit-config.yaml | 11 +++-------- 1 file changed, 3 insertions(+), 8 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 31ff62c..3b8912e 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,18 +1,13 @@ exclude: ^(docsrc/|docs/|examples/) repos: - repo: https://github.com/psf/black - rev: 23.1.0 + rev: 22.8.0 hooks: - id: black - name: black - language: system - entry: black - minimum_pre_commit_version: 2.9.2 - require_serial: true - types: [python] + language_version: python3.11 - repo: https://github.com/PyCQA/flake8 - rev: 6.0.0 + rev: 4.0.1 hooks: - id: flake8 From fc76bbbcf69d2d17855292c4f3962cbb6dedb527 Mon Sep 17 00:00:00 2001 From: Mitch Kelly Date: Thu, 16 Feb 2023 23:15:32 +1100 Subject: [PATCH 07/54] fix: resolves #175 - deleting a ZIA URL category will empty all other custom category fields feat: implements a new ZIA URL category method that allows selective deletion of URLs and keywords test: updates test suite and adds new test --- pyzscaler/zia/url_categories.py | 60 +++++++++++++++++++++++++++++--- tests/zia/test_url_categories.py | 46 ++++++++++++++++++++++-- 2 files changed, 98 insertions(+), 8 deletions(-) diff --git a/pyzscaler/zia/url_categories.py b/pyzscaler/zia/url_categories.py index 63ba2e9..b85d449 100644 --- a/pyzscaler/zia/url_categories.py +++ b/pyzscaler/zia/url_categories.py @@ -159,8 +159,6 @@ def add_url_category(self, name: str, super_category: str, urls: list, **kwargs) for key, value in kwargs.items(): payload[snake_to_camel(key)] = value - print(payload) - return self._post("urlCategories", json=payload) def add_tld_category(self, name: str, tlds: list, **kwargs) -> Box: @@ -282,7 +280,7 @@ def add_urls_to_category(self, category_id: str, urls: list) -> Box: def delete_urls_from_category(self, category_id: str, urls: list) -> Box: """ - Adds URLS to a URL category. + Deletes URLS from a URL category. Args: category_id (str): @@ -298,9 +296,61 @@ def delete_urls_from_category(self, category_id: str, urls: list) -> Box: ... urls=['example.com']) """ + current_config = self.get_category(category_id) + payload = {"configuredName": current_config["configured_name"], "urls": urls} # Required for successful call - payload = convert_keys(self.get_category(category_id)) - payload["urls"] = urls + return self._put(f"urlCategories/{category_id}?action=REMOVE_FROM_LIST", json=payload) + + def delete_from_category(self, category_id: str, **kwargs): + """ + Deletes the specified items from a URL category. + + Args: + category_id (str): + The unique id for the URL category. + **kwargs: + Optional parameters. + + Keyword Args: + keywords (list): + A list of keywords that will be deleted. + keywords_retaining_parent_category (list): + A list of keywords retaining their parent category that will be deleted. + urls (list): + A list of URLs that will be deleted. + db_categorized_urls (list): + A list of URLs retaining their parent category that will be deleted + + Returns: + :obj:`Box`: The updated URL category resource record. + + Examples: + Delete URLs retaining parent category from a custom category: + + >>> zia.url_categories.delete_from_category( + ... category_id="CUSTOM_01", + ... db_categorized_urls=['twitter.com']) + + Delete URLs and URLs retaining parent category from a custom category: + + >>> zia.url_categories.delete_from_category( + ... category_id="CUSTOM_01", + ... urls=['news.com', 'cnn.com'], + ... db_categorized_urls=['google.com, bing.com']) + + """ + current_config = self.get_category(category_id) + + payload = { + "configured_name": current_config["configured_name"], # Required for successful call + } + + # Add optional parameters to payload + for key, value in kwargs.items(): + payload[key] = value + + # Convert snake to camelcase + payload = convert_keys(payload) return self._put(f"urlCategories/{category_id}?action=REMOVE_FROM_LIST", json=payload) diff --git a/tests/zia/test_url_categories.py b/tests/zia/test_url_categories.py index 0663ea9..979ae6c 100644 --- a/tests/zia/test_url_categories.py +++ b/tests/zia/test_url_categories.py @@ -41,7 +41,7 @@ def fixture_custom_url_categories(): "configuredName": "Test URL", "customCategory": True, "customUrlsCount": 2, - "dbCategorizedUrls": [], + "dbCategorizedUrls": ["news.com", "cnn.com"], "description": "Test", "editable": True, "id": "CUSTOM_02", @@ -271,8 +271,7 @@ def test_delete_urls_from_category(zia, custom_categories): json=custom_categories[0], status=200, ) - received_response = custom_categories[0] - received_response["urls"] = ["example.com"] + received_response = {"configuredName": custom_categories[0]["configuredName"], "urls": ["example.com"]} responses.add( method="PUT", url="https://zsapi.zscaler.net/api/v1/urlCategories/CUSTOM_02?action=REMOVE_FROM_LIST", @@ -300,6 +299,47 @@ def test_delete_urls_from_category(zia, custom_categories): assert resp.urls[0] == "test.example.com" +@responses.activate +def test_delete_from_category(zia, custom_categories): + responses.add( + method="GET", + url="https://zsapi.zscaler.net/api/v1/urlCategories/CUSTOM_02", + json=custom_categories[0], + status=200, + ) + received_response = { + "configuredName": custom_categories[0]["configuredName"], + "urls": ["example.com"], + "dbCategorizedUrls": ["news.com"], + } + responses.add( + method="PUT", + url="https://zsapi.zscaler.net/api/v1/urlCategories/CUSTOM_02?action=REMOVE_FROM_LIST", + json={ + "configuredName": "Test URL", + "customCategory": True, + "customUrlsCount": 2, + "dbCategorizedUrls": ["cnn.com"], + "description": "Test", + "editable": True, + "id": "CUSTOM_02", + "keywords": [], + "keywordsRetainingParentCategory": [], + "superCategory": "TEST", + "type": "URL_CATEGORY", + "urls": ["test.example.com"], + "urlsRetainingParentCategoryCount": 0, + "val": 129, + }, + status=200, + match=[matchers.json_params_matcher(received_response)], + ) + resp = zia.url_categories.delete_from_category("CUSTOM_02", urls=["example.com"], db_categorized_urls=["news.com"]) + assert isinstance(resp, Box) + assert resp.urls[0] == "test.example.com" + assert resp.db_categorized_urls[0] == "cnn.com" + + @responses.activate def test_delete_url_category(zia): responses.add( From e7226008c09d975d1e0bb4584148035aa16e4b0d Mon Sep 17 00:00:00 2001 From: Mitch Kelly Date: Mon, 20 Feb 2023 15:21:22 +1100 Subject: [PATCH 08/54] fix: fixes #174, remove hardcoded URL for creating ZPA sessions refactor: move url_base instance attribute from private to public --- pyzscaler/zpa/__init__.py | 12 ++++++------ pyzscaler/zpa/session.py | 8 +++++++- 2 files changed, 13 insertions(+), 7 deletions(-) diff --git a/pyzscaler/zpa/__init__.py b/pyzscaler/zpa/__init__.py index e2daa9f..8466e83 100644 --- a/pyzscaler/zpa/__init__.py +++ b/pyzscaler/zpa/__init__.py @@ -73,19 +73,19 @@ def _build_session(self, **kwargs) -> None: # Configure URL base for this API session if self._override_url: - self._url_base = self._override_url + self.url_base = self._override_url elif not self._cloud or self._cloud == "production": - self._url_base = "https://config.private.zscaler.com" + self.url_base = "https://config.private.zscaler.com" elif self._cloud == "beta": - self._url_base = "https://config.zpabeta.net" + self.url_base = "https://config.zpabeta.net" else: raise ValueError("Missing Attribute: You must specify either cloud or override_url") # Configure URLs for this API session - self._url = f"{self._url_base}/mgmtconfig/v1/admin/customers/{self._customer_id}" - self.user_config_url = f"{self._url_base}/userconfig/v1/customers/{self._customer_id}" + self._url = f"{self.url_base}/mgmtconfig/v1/admin/customers/{self._customer_id}" + self.user_config_url = f"{self.url_base}/userconfig/v1/customers/{self._customer_id}" # The v2 URL supports additional API endpoints - self.v2_url = f"{self._url_base}/mgmtconfig/v2/admin/customers/{self._customer_id}" + self.v2_url = f"{self.url_base}/mgmtconfig/v2/admin/customers/{self._customer_id}" self._auth_token = self.session.create_token(client_id=self._client_id, client_secret=self._client_secret) return self._session.headers.update({"Authorization": f"Bearer {self._auth_token}"}) diff --git a/pyzscaler/zpa/session.py b/pyzscaler/zpa/session.py index 753bca7..cea6921 100644 --- a/pyzscaler/zpa/session.py +++ b/pyzscaler/zpa/session.py @@ -1,7 +1,13 @@ +from restfly import APISession from restfly.endpoint import APIEndpoint class AuthenticatedSessionAPI(APIEndpoint): + def __init__(self, api: APISession): + super().__init__(api) + + self.url_base = api.url_base + def create_token(self, client_id: str, client_secret: str): """ Creates a ZPA authentication token. @@ -24,4 +30,4 @@ def create_token(self, client_id: str, client_secret: str): headers = { "Content-Type": "application/x-www-form-urlencoded", } - return self._post("https://config.private.zscaler.com/signin", headers=headers, data=payload).access_token + return self._post(f"{self.url_base}/signin", headers=headers, data=payload).access_token From 618dd28f44d4489f32f30bb4116c996774d134e3 Mon Sep 17 00:00:00 2001 From: Mitch Kelly Date: Mon, 20 Feb 2023 22:10:08 +1100 Subject: [PATCH 09/54] fix: fixes #160, resolves #162, resolves #165 let ZIA sandbox submission inherit env_cloud --- pyzscaler/zia/__init__.py | 7 +++---- pyzscaler/zia/sandbox.py | 3 ++- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/pyzscaler/zia/__init__.py b/pyzscaler/zia/__init__.py index 5337fd2..9be087a 100644 --- a/pyzscaler/zia/__init__.py +++ b/pyzscaler/zia/__init__.py @@ -58,17 +58,16 @@ class ZIA(APISession): _box = True _box_attrs = {"camel_killer_box": True} _env_base = "ZIA" - _env_cloud = "zscaler" _url = "https://zsapi.zscaler.net/api/v1" + env_cloud = "zscaler" def __init__(self, **kw): self._api_key = kw.get("api_key", os.getenv(f"{self._env_base}_API_KEY")) self._username = kw.get("username", os.getenv(f"{self._env_base}_USERNAME")) self._password = kw.get("password", os.getenv(f"{self._env_base}_PASSWORD")) - self._env_cloud = kw.get("cloud", os.getenv(f"{self._env_base}_CLOUD")) + self.env_cloud = kw.get("cloud", os.getenv(f"{self._env_base}_CLOUD")) self._url = ( - kw.get("override_url", os.getenv(f"{self._env_base}_OVERRIDE_URL")) - or f"https://zsapi.{self._env_cloud}.net/api/v1" + kw.get("override_url", os.getenv(f"{self._env_base}_OVERRIDE_URL")) or f"https://zsapi.{self.env_cloud}.net/api/v1" ) self.conv_box = True self.sandbox_token = kw.get("sandbox_token", os.getenv(f"{self._env_base}_SANDBOX_TOKEN")) diff --git a/pyzscaler/zia/sandbox.py b/pyzscaler/zia/sandbox.py index 9d480e6..beb138c 100644 --- a/pyzscaler/zia/sandbox.py +++ b/pyzscaler/zia/sandbox.py @@ -8,6 +8,7 @@ def __init__(self, api: APISession): super().__init__(api) self.sandbox_token = api.sandbox_token + self.env_cloud = api.env_cloud def submit_file(self, file: str, force: bool = False) -> Box: """ @@ -34,7 +35,7 @@ def submit_file(self, file: str, force: bool = False) -> Box: "force": int(force), # convert boolean to int for ZIA } - return self._post("https://csbapi.zscaler.net/zscsb/submit", params=params, data=data) + return self._post(f"https://csbapi.{self.env_cloud}.net/zscsb/submit", params=params, data=data) def get_quota(self) -> Box: """ From ea6f19067319cf5ce3c0d68b7e3414ffef4f3d28 Mon Sep 17 00:00:00 2001 From: Mitch Kelly Date: Mon, 20 Feb 2023 22:36:31 +1100 Subject: [PATCH 10/54] chore: remove pre-commit dependencies from dev_requirements chore: update pyzscaler dependencies to latest supported versions. closes #156, closes #161, closes #163, closes #164, closes #170, closes #172, closes #173 --- .pre-commit-config.yaml | 4 ++-- dev_requirements.txt | 13 ++++++------- pyproject.toml | 14 +++++++------- requirements.txt | 2 +- 4 files changed, 16 insertions(+), 17 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 0ccebc4..31ff62c 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,7 +1,7 @@ exclude: ^(docsrc/|docs/|examples/) repos: - repo: https://github.com/psf/black - rev: 22.8.0 + rev: 23.1.0 hooks: - id: black name: black @@ -12,7 +12,7 @@ repos: types: [python] - repo: https://github.com/PyCQA/flake8 - rev: 4.0.1 + rev: 6.0.0 hooks: - id: flake8 diff --git a/dev_requirements.txt b/dev_requirements.txt index 5ce28b2..e140242 100644 --- a/dev_requirements.txt +++ b/dev_requirements.txt @@ -1,10 +1,9 @@ -black==22.10.0 -sphinx==5.3.0 +furo==2022.12.7 +pre-commit==3.0.4 +pytest==7.2.1 +python-box==7.0.0 restfly==1.4.7 -python-box==6.1.0 -furo==2022.9.29 -pre-commit==2.20.0 -pytest==7.2.0 +requests==2.28.2 responses==0.22.0 -requests==2.28.1 +sphinx==6.1.3 toml==0.10.2 \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index 78b4b0b..e0db458 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -32,17 +32,17 @@ include = [ [tool.poetry.dependencies] python = "^3.7" restfly = "1.4.7" -python-box = "6.1.0" +python-box = "7.0.0" [tool.poetry.dev-dependencies] python = "^3.7" restfly = "1.4.7" -python-box = "6.1.0" -Sphinx = "5.3.0" -furo = "2022.9.29" -pytest = "7.2.0" -requests = "2.28.1" -pre-commit = "2.20.0" +python-box = "7.0.0" +sphinx = "6.1.3" +furo = "2022.12.7" +pytest = "7.2.1" +requests = "2.28.2" +pre-commit = "3.0.4" responses = "0.22.0" toml = "0.10.2" diff --git a/requirements.txt b/requirements.txt index 8d91e45..5415bb4 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,2 +1,2 @@ restfly==1.4.7 -python-box==6.1.0 \ No newline at end of file +python-box==7.0.0 \ No newline at end of file From 00751cea95bb0789d8e2caab460ed2811a581a42 Mon Sep 17 00:00:00 2001 From: Mitch Kelly Date: Mon, 20 Feb 2023 22:39:56 +1100 Subject: [PATCH 11/54] docs: fix malformed docstring formatting Signed-off-by: Mitch Kelly --- pyzscaler/zia/admin_and_role_management.py | 78 +++++++++++++++------- 1 file changed, 55 insertions(+), 23 deletions(-) diff --git a/pyzscaler/zia/admin_and_role_management.py b/pyzscaler/zia/admin_and_role_management.py index ff7ea0a..655ca0d 100644 --- a/pyzscaler/zia/admin_and_role_management.py +++ b/pyzscaler/zia/admin_and_role_management.py @@ -8,6 +8,7 @@ class AdminAndRoleManagementAPI(APIEndpoint): def add_user(self, name: str, login_name: str, email: str, password: str, **kwargs) -> Box: """ Adds a new admin user to ZIA. + Args: name (str): The user's full name. login_name (str): @@ -15,6 +16,7 @@ def add_user(self, name: str, login_name: str, email: str, password: str, **kwar email (str): The email address for the admin user. password (str): The password for the admin user. **kwargs: Optional keyword args. + Keyword Args: admin_scope (str): The scope of the admin's permissions, accepted values are: ``organization``, ``department``, ``location``, ``location_group`` @@ -37,30 +39,36 @@ def add_user(self, name: str, login_name: str, email: str, password: str, **kwar ``department`` then you will need to provide a list of department ids. **NOTE**: This param doesn't need to be provided if the admin user's scope is set to ``organization``. + Returns: :obj:`Box`: The newly created admin user resource record. + Examples: + Add an admin user with the minimum required params: - >>> admin_user = zia.admin_and_role_management.add_user( - ... name="Jim Bob", - ... login_name="jim@example.com", - ... password="hunter2", - ... email="jim@example.com") + >>> admin_user = zia.admin_and_role_management.add_user( + ... name="Jim Bob", + ... login_name="jim@example.com", + ... password="hunter2", + ... email="jim@example.com") + Add an admin user with a department admin scope: - >>> admin_user = zia.admin_and_role_management.add_user( - ... name="Jane Bob", - ... login_name="jane@example.com", - ... password="hunter3", - ... email="jane@example.com, - ... admin_scope="department", - ... scope_ids = ['376542', '245688']) + >>> admin_user = zia.admin_and_role_management.add_user( + ... name="Jane Bob", + ... login_name="jane@example.com", + ... password="hunter3", + ... email="jane@example.com, + ... admin_scope="department", + ... scope_ids = ['376542', '245688']) + Add an auditor user: - >>> auditor_user = zia.admin_and_role_management.add_user( - ... name="Head Bob", - ... login_name="head@example.com", - ... password="hunter4", - ... email="head@example.com, - ... is_auditor=True) + >>> auditor_user = zia.admin_and_role_management.add_user( + ... name="Head Bob", + ... login_name="head@example.com", + ... password="hunter4", + ... email="head@example.com, + ... is_auditor=True) + """ payload = { "userName": name, @@ -98,6 +106,7 @@ def add_user(self, name: str, login_name: str, email: str, password: str, **kwar def list_users(self, **kwargs) -> BoxList: """ Returns a list of admin users. + Keyword Args: **include_auditor_users (bool, optional): Include or exclude auditor user information in the list. @@ -109,26 +118,34 @@ def list_users(self, **kwargs) -> BoxList: Specifies the page offset. **page_size (int, optional): Specifies the page size. The default size is 100, but the maximum size is 1000. + Returns: :obj:`BoxList`: The admin_users resource record. + Examples: >>> users = zia.admin_and_role_management.list_users(search='login_name') + """ return BoxList(Iterator(self._api, "adminUsers", **kwargs)) def list_roles(self, **kwargs) -> BoxList: """ Return a list of the configured admin roles in ZIA. + Args: **kwargs: Optional keyword args. + Keyword Args: include_auditor_role (bool): Set to ``True`` to include auditor role information in the response. include_partner_role (bool): Set to ``True`` to include partner admin role information in the response. + Returns: :obj:`BoxList`: A list of admin role resource records. + Examples: Get a list of all configured admin roles: >>> roles = zia.admin_and_management_roles.list_roles() + """ payload = {snake_to_camel(key): value for key, value in kwargs.items()} @@ -137,12 +154,16 @@ def list_roles(self, **kwargs) -> BoxList: def get_user(self, user_id: str) -> Box: """ Returns information on the specified admin user id. + Args: user_id (str): The unique id of the admin user. + Returns: :obj:`Box`: The admin user resource record. + Examples: >>> print(zia.admin_and_role_management.get_user('987321202')) + """ admin_user = next(user for user in self.list_users() if user.id == int(user_id)) @@ -151,12 +172,16 @@ def get_user(self, user_id: str) -> Box: def delete_user(self, user_id: str) -> int: """ Deletes the specified admin user by id. + Args: user_id (str): The unique id of the admin user. + Returns: :obj:`int`: The response code for the request. + Examples: >>> zia.admin_role_management.delete_admin_user('99272455') + """ return self._delete(f"adminUsers/{user_id}", box=False).status_code @@ -164,9 +189,11 @@ def delete_user(self, user_id: str) -> int: def update_user(self, user_id: str, **kwargs) -> dict: """ Update an admin user. + Args: user_id (str): The unique id of the admin user to be updated. **kwargs: Optional keyword args. + Keyword Args: admin_scope (str): The scope of the admin's permissions, accepted values are: ``organization``, ``department``, ``location``, ``location_group`` @@ -192,16 +219,21 @@ def update_user(self, user_id: str, **kwargs) -> dict: ``department`` then you will need to provide a list of department ids. **NOTE:** This param doesn't need to be provided if the admin user's scope is set to `organization`. + Returns: :obj:`dict`: The updated admin user resource record. + Examples: + Update the email address for an admin user: - >>> user = zia.admin_and_role_management.update_user('99695301', - ... email='jimbob@example.com') + >>> user = zia.admin_and_role_management.update_user('99695301', + ... email='jimbob@example.com') + Update the admin scope for an admin user to department: - >>> user = zia.admin_and_role_management.update_user('99695301', - ... admin_scope='department', - ... scope_ids=['3846532', '3846541']) + >>> user = zia.admin_and_role_management.update_user('99695301', + ... admin_scope='department', + ... scope_ids=['3846532', '3846541']) + """ # Get the resource record for the provided user id From 04b404e274acf30aaff0606dba33420e8b88386f Mon Sep 17 00:00:00 2001 From: Mitch Kelly Date: Mon, 20 Feb 2023 22:43:50 +1100 Subject: [PATCH 12/54] chore: fix formatting with Black Signed-off-by: Mitch Kelly --- pyzscaler/zpa/service_edges.py | 1 - tests/zcc/test_zcc_session.py | 1 - tests/zia/test_security.py | 1 - tests/zia/test_session.py | 1 - 4 files changed, 4 deletions(-) diff --git a/pyzscaler/zpa/service_edges.py b/pyzscaler/zpa/service_edges.py index fac16cd..eaa35f1 100644 --- a/pyzscaler/zpa/service_edges.py +++ b/pyzscaler/zpa/service_edges.py @@ -10,7 +10,6 @@ class ServiceEdgesAPI(APIEndpoint): - # Parameter names that will be reformatted to be compatible with ZPAs API reformat_params = [ ("service_edge_ids", "serviceEdges"), diff --git a/tests/zcc/test_zcc_session.py b/tests/zcc/test_zcc_session.py index e55e5d1..881e870 100644 --- a/tests/zcc/test_zcc_session.py +++ b/tests/zcc/test_zcc_session.py @@ -3,7 +3,6 @@ @responses.activate def test_create_token(zcc, session): - responses.add( responses.POST, url="https://api-mobile.zscaler.net/papi/auth/v1/login", diff --git a/tests/zia/test_security.py b/tests/zia/test_security.py index 72290c3..ad13f68 100644 --- a/tests/zia/test_security.py +++ b/tests/zia/test_security.py @@ -109,7 +109,6 @@ def test_delete_urls_from_whitelist(zia, whitelist_urls): @responses.activate def test_add_urls_to_blacklist(zia, blacklist_urls): - blacklist_urls["blacklistUrls"].append("mysite.com") responses.add( diff --git a/tests/zia/test_session.py b/tests/zia/test_session.py index df5a69a..92c3466 100644 --- a/tests/zia/test_session.py +++ b/tests/zia/test_session.py @@ -3,7 +3,6 @@ @responses.activate def test_create(zia, session): - responses.add( responses.POST, url="https://zsapi.zscaler.net/api/v1/authenticatedSession", From 941bd9c4a17aacb570118c8c48f416dfa8e80f74 Mon Sep 17 00:00:00 2001 From: Mitch Kelly Date: Mon, 20 Feb 2023 22:44:14 +1100 Subject: [PATCH 13/54] Bumps version to 1.4.1 Signed-off-by: Mitch Kelly --- docsrc/conf.py | 2 +- pyproject.toml | 2 +- pyzscaler/__init__.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/docsrc/conf.py b/docsrc/conf.py index b017212..990de03 100644 --- a/docsrc/conf.py +++ b/docsrc/conf.py @@ -27,7 +27,7 @@ # The short X.Y version version = '1.4' # The full version, including alpha/beta/rc tags -release = '1.4.0' +release = '1.4.1' # -- General configuration --------------------------------------------------- diff --git a/pyproject.toml b/pyproject.toml index e0db458..d6c7174 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "pyzscaler" -version = "1.4.0" +version = "1.4.1" description = "A python SDK for the Zscaler API." authors = ["Mitch Kelly "] license = "MIT" diff --git a/pyzscaler/__init__.py b/pyzscaler/__init__.py index de5b9b6..ddf3d6e 100644 --- a/pyzscaler/__init__.py +++ b/pyzscaler/__init__.py @@ -4,7 +4,7 @@ "Dax Mickelson", "Jacob Gårder", ] -__version__ = "1.4.0" +__version__ = "1.4.1" from pyzscaler.zcc import ZCC # noqa from pyzscaler.zdx import ZDX # noqa From 04928b76cf52e6ef0e3bf2974711a44c8b0c3a38 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=81lvaro=20Gonz=C3=A1lez?= Date: Wed, 22 Mar 2023 23:28:26 +0100 Subject: [PATCH 14/54] docs: fix zpa.session example code (#185) --- pyzscaler/zpa/session.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyzscaler/zpa/session.py b/pyzscaler/zpa/session.py index cea6921..0687768 100644 --- a/pyzscaler/zpa/session.py +++ b/pyzscaler/zpa/session.py @@ -20,7 +20,7 @@ def create_token(self, client_id: str, client_secret: str): :obj:`dict`: The authenticated session information. Examples: - >>> zpa.session.create(client_id='xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx==', + >>> zpa.session.create_token(client_id='xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx==', ... client_secret='yyyyyyyyyyyyyyyyyyyyyyyyyyyyyy') """ From 011926f8733b06f1c4c2c400d84aa2c423b0c520 Mon Sep 17 00:00:00 2001 From: david-kn <14314517+david-kn@users.noreply.github.com> Date: Wed, 15 Mar 2023 05:07:15 +0100 Subject: [PATCH 15/54] feat: support ZPA policies complex conditions (#166) --- pyzscaler/zpa/policies.py | 66 +++++++++++++++++++++++++++++----- tests/zpa/test_zpa_policies.py | 50 ++++++++++++++++++++++++++ 2 files changed, 108 insertions(+), 8 deletions(-) diff --git a/pyzscaler/zpa/policies.py b/pyzscaler/zpa/policies.py index 9faac35..d0c965c 100644 --- a/pyzscaler/zpa/policies.py +++ b/pyzscaler/zpa/policies.py @@ -13,26 +13,76 @@ class PolicySetsAPI(APIEndpoint): } @staticmethod - def _create_conditions(conditions: list): + def _create_conditions(conditions: list) -> list: """ - Creates a dict template for feeding conditions into the ZPA Policies API when adding or updating a policy. + Creates a list template for feeding conditions into the ZPA Policies API when adding or updating a policy. Args: - conditions (list): List of condition tuples. + conditions (list): List of condition tuples or lists (containing more complex logic statements). Returns: - :obj:`dict`: The conditions template. + :obj:`list`: The conditions template. """ - template = [] - + final_template = [] for condition in conditions: + if isinstance(condition, list): + template, operator = PolicySetsAPI._parse_condition(condition) + + if operator: + expression = {"operands": template, "operator": operator.upper()} + else: + expression = {"operands": template} + final_template.append(expression) + + # for backward compatibility: if isinstance(condition, tuple) and len(condition) == 3: - operand = {"operands": [{"objectType": condition[0].upper(), "lhs": condition[1], "rhs": condition[2]}]} + template = PolicySetsAPI._format_tuple_condition(condition) + final_template.append({"operands": [template]}) + + return final_template + + @staticmethod + def _format_tuple_condition(condition: tuple): + """Formats a simple tuple condition + + Args: + condition (tuple): A condition tuple + + Returns: + dict[str, str]: Formatted dict structure for ZIA Policies API + """ + return { + "objectType": condition[0].upper(), + "lhs": condition[1], + "rhs": condition[2], + } + + @staticmethod + def _parse_condition(condition: list): + """ + Transforms a single statement with operand into a format for a condition template. + + Args: + conditions (list): A single list of condition statement + + Returns: + :obj:`list`: The conditions template. + :obj:`string`: The type of operator within conditon (AND | OR) + + """ + template = [] + operator = 0 + + for parameter in condition: + if isinstance(parameter, str) and (parameter.upper() == "OR" or parameter.upper() == "AND"): + operator = parameter + if isinstance(parameter, tuple) and len(parameter) == 3: + operand = PolicySetsAPI._format_tuple_condition(parameter) template.append(operand) - return template + return template, operator def get_policy(self, policy_type: str) -> Box: """ diff --git a/tests/zpa/test_zpa_policies.py b/tests/zpa/test_zpa_policies.py index f05f2ba..d728ef7 100644 --- a/tests/zpa/test_zpa_policies.py +++ b/tests/zpa/test_zpa_policies.py @@ -13,6 +13,28 @@ def fixture_policies(): return {"totalPages": 1, "list": [{"id": "1"}, {"id": "2"}, {"id": "3"}]} +@pytest.fixture(name="policy_conditions") +def fixture_policy_conditions(): + return [ + [ + ("app", "id", "216197915188658453"), + ("app", "id", "216197915188658455"), + "OR", + ], + [ + ("scim", "216197915188658304", "john.doe@foo.bar"), + ("scim", "216197915188658304", "foo.bar"), + "OR", + ], + ("scim_group", "216197915188658303", "241874"), # check backward compatibility + [ + ("posture", "fc92ead2-4046-428d-bf3f-6e534a53194b", "TRUE"), + ("posture", "490db9b4-96d8-4035-9b5e-935daa697f45", "TRUE"), + "AND", + ], + ] + + @pytest.fixture(name="policy_rules") def fixture_policy_rules(): return { @@ -116,6 +138,34 @@ def test_list_policy_rules_error(zpa, policy_rules): resp = zpa.policies.list_rules("test") +def test_create_conditions(zpa, policy_conditions): + conditions = zpa.policies._create_conditions(policy_conditions) + assert conditions == [ + { + "operands": [ + {"objectType": "APP", "lhs": "id", "rhs": "216197915188658453"}, + {"objectType": "APP", "lhs": "id", "rhs": "216197915188658455"}, + ], + "operator": "OR", + }, + { + "operands": [ + {"objectType": "SCIM", "lhs": "216197915188658304", "rhs": "john.doe@foo.bar"}, + {"objectType": "SCIM", "lhs": "216197915188658304", "rhs": "foo.bar"}, + ], + "operator": "OR", + }, + {"operands": [{"objectType": "SCIM_GROUP", "lhs": "216197915188658303", "rhs": "241874"}]}, + { + "operands": [ + {"objectType": "POSTURE", "lhs": "fc92ead2-4046-428d-bf3f-6e534a53194b", "rhs": "TRUE"}, + {"objectType": "POSTURE", "lhs": "490db9b4-96d8-4035-9b5e-935daa697f45", "rhs": "TRUE"}, + ], + "operator": "AND", + }, + ] + + @responses.activate def test_get_access_policy(zpa, policies): responses.add( From 15f175e91f5932c80188359462b9628993176462 Mon Sep 17 00:00:00 2001 From: Mitch Kelly Date: Thu, 23 Mar 2023 16:08:04 +1100 Subject: [PATCH 16/54] refactor: convert policy template methods from static to class methods. optimise functions. --- pyzscaler/zpa/policies.py | 86 +++++++++++++++++++-------------------- 1 file changed, 41 insertions(+), 45 deletions(-) diff --git a/pyzscaler/zpa/policies.py b/pyzscaler/zpa/policies.py index d0c965c..d8f5136 100644 --- a/pyzscaler/zpa/policies.py +++ b/pyzscaler/zpa/policies.py @@ -12,8 +12,7 @@ class PolicySetsAPI(APIEndpoint): "siem": "SIEM_POLICY", } - @staticmethod - def _create_conditions(conditions: list) -> list: + def _create_conditions(self, conditions: list) -> list: """ Creates a list template for feeding conditions into the ZPA Policies API when adding or updating a policy. @@ -21,68 +20,61 @@ def _create_conditions(conditions: list) -> list: conditions (list): List of condition tuples or lists (containing more complex logic statements). Returns: - :obj:`list`: The conditions template. + list: The conditions template. """ - final_template = [] for condition in conditions: - if isinstance(condition, list): - template, operator = PolicySetsAPI._parse_condition(condition) - - if operator: - expression = {"operands": template, "operator": operator.upper()} - else: - expression = {"operands": template} - final_template.append(expression) - - # for backward compatibility: - if isinstance(condition, tuple) and len(condition) == 3: - template = PolicySetsAPI._format_tuple_condition(condition) - final_template.append({"operands": [template]}) - + template, operator = self._parse_condition(condition) + expression = {"operands": template} + if operator: + expression["operator"] = operator.upper() + final_template.append(expression) return final_template - @staticmethod - def _format_tuple_condition(condition: tuple): - """Formats a simple tuple condition + def _parse_condition(self, condition): + """ + Transforms a single statement with operand into a format for a condition template. Args: - condition (tuple): A condition tuple + condition: A single list of condition statements or a tuple. Returns: - dict[str, str]: Formatted dict structure for ZIA Policies API + tuple: A tuple containing the formatted condition template (list) and the operator (str, "AND" or "OR") + or None. + """ - return { - "objectType": condition[0].upper(), - "lhs": condition[1], - "rhs": condition[2], - } + # If this is a tuple condition on its own, process it now + if isinstance(condition, tuple) and len(condition) == 3: + return [self._format_condition_tuple(condition)], None + + # Otherwise we'd expect a list of tuples and an optional operator at the end + template = [] + operator = None + for parameter in condition: + if isinstance(parameter, str) and parameter.upper() in ["OR", "AND"]: + operator = parameter + elif isinstance(parameter, tuple) and len(parameter) == 3: + template.append(self._format_condition_tuple(parameter)) + return template, operator @staticmethod - def _parse_condition(condition: list): + def _format_condition_tuple(condition: tuple): """ - Transforms a single statement with operand into a format for a condition template. + Formats a simple tuple condition. Args: - conditions (list): A single list of condition statement + condition (tuple): A condition tuple (objectType, lhs, rhs). Returns: - :obj:`list`: The conditions template. - :obj:`string`: The type of operator within conditon (AND | OR) + dict: Formatted dict structure for ZIA Policies API. """ - template = [] - operator = 0 - - for parameter in condition: - if isinstance(parameter, str) and (parameter.upper() == "OR" or parameter.upper() == "AND"): - operator = parameter - if isinstance(parameter, tuple) and len(parameter) == 3: - operand = PolicySetsAPI._format_tuple_condition(parameter) - template.append(operand) - - return template, operator + return { + "objectType": condition[0].upper(), + "lhs": condition[1], + "rhs": condition[2], + } def get_policy(self, policy_type: str) -> Box: """ @@ -248,7 +240,11 @@ def add_access_rule(self, name: str, action: str, **kwargs) -> Box: """ # Initialise the payload - payload = {"name": name, "action": action.upper(), "conditions": self._create_conditions(kwargs.pop("conditions", []))} + payload = { + "name": name, + "action": action.upper(), + "conditions": self._create_conditions(kwargs.pop("conditions", [])), + } # Get the policy id of the provided policy type for the URL. policy_id = self.get_policy("access").id From a12478c5183cef93270a4af6ad110c43be1a4f2f Mon Sep 17 00:00:00 2001 From: Mitch Kelly Date: Tue, 4 Apr 2023 05:48:01 +1000 Subject: [PATCH 17/54] fix: fixes an issue where python black doesn't run correctly Signed-off-by: Mitch Kelly --- .pre-commit-config.yaml | 11 +++-------- 1 file changed, 3 insertions(+), 8 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 31ff62c..3b8912e 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,18 +1,13 @@ exclude: ^(docsrc/|docs/|examples/) repos: - repo: https://github.com/psf/black - rev: 23.1.0 + rev: 22.8.0 hooks: - id: black - name: black - language: system - entry: black - minimum_pre_commit_version: 2.9.2 - require_serial: true - types: [python] + language_version: python3.11 - repo: https://github.com/PyCQA/flake8 - rev: 6.0.0 + rev: 4.0.1 hooks: - id: flake8 From 4a91db8ce6ac5383dc7f18d02aa61776c3afe789 Mon Sep 17 00:00:00 2001 From: Mitch Kelly Date: Tue, 18 Oct 2022 11:31:49 +1100 Subject: [PATCH 18/54] feat: Adds initial support for ZDX --- pyzscaler/zdx/__init__.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/pyzscaler/zdx/__init__.py b/pyzscaler/zdx/__init__.py index 696a2b8..ae64dcb 100644 --- a/pyzscaler/zdx/__init__.py +++ b/pyzscaler/zdx/__init__.py @@ -4,6 +4,7 @@ from restfly.session import APISession from pyzscaler import __version__ + from .admin import AdminAPI from .apps import AppsAPI from .session import SessionAPI @@ -68,4 +69,8 @@ def admin(self): @property def apps(self): """The interface object for the :ref:`ZDX Apps interface `.""" +<<<<<<< HEAD +======= + print(f"Headers are: {self._session.headers}") +>>>>>>> ea72812 (feat: Adds initial support for ZDX) return AppsAPI(self) From 7d83afa6b22ee36152b1649774d2dc02d40b6013 Mon Sep 17 00:00:00 2001 From: Mitch Kelly Date: Tue, 4 Apr 2023 06:05:10 +1000 Subject: [PATCH 19/54] feat: adds support for ZDX devices API endpoints --- pyzscaler/utils.py | 29 ++++ pyzscaler/zdx/__init__.py | 16 ++- pyzscaler/zdx/devices.py | 286 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 325 insertions(+), 6 deletions(-) create mode 100644 pyzscaler/zdx/devices.py diff --git a/pyzscaler/utils.py b/pyzscaler/utils.py index 6d7631a..9ed07dd 100644 --- a/pyzscaler/utils.py +++ b/pyzscaler/utils.py @@ -151,3 +151,32 @@ def _get_page(self) -> None: "quarantined": 6, }, } + + +def calculate_epoch(hours: int): + current_time = int(time.time()) + past_time = int(current_time - (hours * 3600)) + return current_time, past_time + + +def zdx_params(func): + """ + Decorator to add custom parameter functionality for ZDX API calls. + + Args: + func: The function to decorate. + + Returns: + The decorated function. + + """ + + def wrapper(self, **kwargs): + if kwargs.get("since"): + current_time, past_time = calculate_epoch(kwargs.get("since")) + kwargs["to"] = current_time + kwargs["from"] = past_time + del kwargs["since"] + return func(self, **kwargs) + + return wrapper diff --git a/pyzscaler/zdx/__init__.py b/pyzscaler/zdx/__init__.py index ae64dcb..e2f1a7f 100644 --- a/pyzscaler/zdx/__init__.py +++ b/pyzscaler/zdx/__init__.py @@ -7,6 +7,7 @@ from .admin import AdminAPI from .apps import AppsAPI +from .devices import DevicesAPI from .session import SessionAPI @@ -17,11 +18,12 @@ class ZDX(APISession): The ZDX object stores the session token and simplifies access to CRUD options within the ZDX Portal. Attributes: - client_id (str): The ZDX API Client ID generated from the ZSX Portal. - client_secret (str): The ZDX API Client Secret generated from the ZDX Portal. - cloud (str): The Zscaler cloud for your tenancy, current working values are: + client_id (str): The ZDX Client ID generated from the ZDX Portal. + client_secret (str): The ZDX Client Secret generated from the ZDX Portal. + cloud (str): The Zscaler cloud for your tenancy, accepted values are below. Defaults to ``zdxcloud``. * ``zdxcloud`` + * ``zdxbeta`` override_url (str): If supplied, this attribute can be used to override the production URL that is derived @@ -69,8 +71,10 @@ def admin(self): @property def apps(self): """The interface object for the :ref:`ZDX Apps interface `.""" -<<<<<<< HEAD -======= print(f"Headers are: {self._session.headers}") ->>>>>>> ea72812 (feat: Adds initial support for ZDX) return AppsAPI(self) + + @property + def devices(self): + """The interface object for the :ref:`ZDX Devices interface `.""" + return DevicesAPI(self) diff --git a/pyzscaler/zdx/devices.py b/pyzscaler/zdx/devices.py new file mode 100644 index 0000000..9c7e8c6 --- /dev/null +++ b/pyzscaler/zdx/devices.py @@ -0,0 +1,286 @@ +from restfly.endpoint import APIEndpoint + +from pyzscaler.utils import zdx_params + + +class DevicesAPI(APIEndpoint): + @zdx_params + def list_devices(self, **kwargs): + """ + Returns a list of all devices in ZDX. + + Keyword Args: + since (int): The number of hours to look back for devices. + location_id (str): The unique ID for the location. + department_id (str): The unique ID for the department. + geo_id (str): The unique ID for the geolocation. + + Returns: + :obj:`BoxList`: The list of devices in ZDX. + + Examples: + List all devices in ZDX for the past 2 hours + >>> for device in zdx.devices.list_devices(): + + List all devices in ZDX for the past 24 hours + >>> for device in zdx.devices.list_devices(since=24): + + """ + return self._get("devices", params=kwargs) + + @zdx_params + def get_device(self, device_id: str, **kwargs): + """ + Returns a single device in ZDX. + + Args: + device_id (str): The unique ID for the device. + + Keyword Args: + since (int): The number of hours to look back for devices. + + Returns: + :obj:`Box`: The ZDX device resource record. + + Examples: + Get information for the device with an ID of 123456789. + >>> device = zdx.devices.get_device('123456789') + + Get information for the device with an ID of 123456789 for the last 24 hours. + >>> device = zdx.devices.get_device('123456789', since=24) + + """ + return self._get(f"devices/{device_id}", params=kwargs) + + @zdx_params + def get_device_apps(self, device_id: str, **kwargs): + """ + Returns a list of all active applications for a device. + + Args: + device_id (str): The unique ID for the device. + + Keyword Args: + since (int): The number of hours to look back for devices. + + Returns: + :obj:`BoxList`: The list of active applications for the device. + + Examples: + Print a list of active applications for a device. + + >>> for app in zdx.devices.get_device_apps('123456789'): + ... print(app) + + Print a list of active applications for a device for the last 24 hours. + + >>> for app in zdx.devices.get_device_apps('123456789', since=24): + ... print(app) + + """ + return self._get(f"devices/{device_id}/apps", params=kwargs) + + def get_device_app(self, device_id: str, app_id: str): + """ + Returns a single application for a device. + + Args: + device_id (str): The unique ID for the device. + app_id (str): The unique ID for the application. + + Returns: + :obj:`Box`: The application resource record. + + Examples: + Print a single application for a device. + + >>> app = zdx.devices.get_device_app('123456789', '987654321') + ... print(app) + + """ + return self._get(f"devices/{device_id}/apps/{app_id}") + + def get_web_probes(self, device_id: str, app_id: str): + """ + Returns a list of all active web probes for a specific application being used by a device. + + Args: + device_id (str): The unique ID for the device. + app_id (str): The unique ID for the application. + + Returns: + :obj:`BoxList`: The list of web probes for the application. + + Examples: + Print a list of web probes for an application. + + >>> for probe in zdx.devices.get_device_app_webprobes('123456789', '987654321'): + ... print(probe) + + """ + return self._get(f"devices/{device_id}/apps/{app_id}/web-probes") + + @zdx_params + def get_web_probe(self, device_id: str, app_id: str, probe_id: str, **kwargs): + """ + Returns a single web probe for a specific application being used by a device. + + Args: + device_id (str): The unique ID for the device. + app_id (str): The unique ID for the application. + probe_id (str): The unique ID for the web probe. + + Keyword Args: + since (int): The number of hours to look back for devices. + + Returns: + :obj:`Box`: The web probe resource record. + + Examples: + Print a single web probe for an application. + + >>> probe = zdx.devices.get_web_probe('123456789', '987654321', '123987456') + ... print(probe) + + """ + return self._get(f"devices/{device_id}/apps/{app_id}/web-probes/{probe_id}", params=kwargs) + + @zdx_params + def list_cloudpath_probes(self, device_id: str, app_id: str, **kwargs): + """ + Returns a list of all active cloudpath probes for a specific application being used by a device. + + Args: + device_id (str): The unique ID for the device. + app_id (str): The unique ID for the application. + + Keyword Args: + since (int): The number of hours to look back for devices. + + Returns: + :obj:`BoxList`: The list of cloudpath probes for the application. + + Examples: + Print a list of cloudpath probes for an application. + + >>> for probe in zdx.devices.list_cloudpath_probes('123456789', '987654321'): + ... print(probe) + + """ + return self._get(f"devices/{device_id}/apps/{app_id}/cloudpath-probes", params=kwargs) + + @zdx_params + def get_cloudpath_probe(self, device_id: str, app_id: str, probe_id: str, **kwargs): + """ + Returns a single cloudpath probe for a specific application being used by a device. + + Args: + device_id (str): The unique ID for the device. + app_id (str): The unique ID for the application. + probe_id (str): The unique ID for the cloudpath probe. + + Keyword Args: + since (int): The number of hours to look back for devices. + + Returns: + :obj:`Box`: The cloudpath probe resource record. + + Examples: + Print a single cloudpath probe for an application. + + >>> probe = zdx.devices.get_cloudpath_probe('123456789', '987654321', '123987456') + ... print(probe) + + """ + return self._get(f"devices/{device_id}/apps/{app_id}/cloudpath-probes/{probe_id}", params=kwargs) + + @zdx_params + def get_cloudpath(self, device_id: str, app_id: str, probe_id: str, **kwargs): + """ + Returns a single cloudpath for a specific application being used by a device. + + Args: + device_id (str): The unique ID for the device. + app_id (str): The unique ID for the application. + probe_id (str): The unique ID for the cloudpath probe. + + Keyword Args: + since (int): The number of hours to look back for devices. + + Returns: + :obj:`Box`: The cloudpath resource record. + + Examples: + Print a single cloudpath for an application. + + >>> cloudpath = zdx.devices.get_cloudpath('123456789', '987654321', '123987456') + ... print(cloudpath) + + """ + return self._get(f"devices/{device_id}/apps/{app_id}/cloudpath-probes/{probe_id}/cloudpath", params=kwargs) + + @zdx_params + def get_call_quality_metrics(self, device_id: str, app_id: str, **kwargs): + """ + Returns a single call quality metrics for a specific application being used by a device. + + Args: + device_id (str): The unique ID for the device. + app_id (str): The unique ID for the application. + + Keyword Args: + since (int): The number of hours to look back for devices. + + Returns: + :obj:`Box`: The call quality metrics resource record. + + Examples: + Print call quality metrics for an application. + + >>> metrics = zdx.devices.get_call_quality_metrics('123456789', '987654321') + ... print(metrics) + + """ + return self._get(f"devices/{device_id}/apps/{app_id}/call-quality-metrics", params=kwargs) + + @zdx_params + def get_health_metrics(self, device_id: str, **kwargs): + """ + Returns health metrics trend for a specific device. + + Args: + device_id (str): The unique ID for the device. + + Keyword Args: + since (int): The number of hours to look back for devices. + + Returns: + :obj:`Box`: The health metrics resource record. + + Examples: + Print health metrics for an application. + + >>> metrics = zdx.devices.get_health_metrics('123456789') + ... print(metrics) + + """ + return self._get(f"devices/{device_id}/health-metrics", params=kwargs) + + def get_events(self, device_id: str): + """ + Returns a list of all events for a specific device. + + Args: + device_id (str): The unique ID for the device. + + Returns: + :obj:`BoxList`: The list of events for the device. + + Examples: + Print a list of events for a device. + + >>> for event in zdx.devices.get_events('123456789'): + ... print(event) + + """ + return self._get(f"devices/{device_id}/events") From be0068d4180415e19cd31698563d0dd80b54a13b Mon Sep 17 00:00:00 2001 From: Mitch Kelly Date: Tue, 4 Apr 2023 06:05:56 +1000 Subject: [PATCH 20/54] docs: adds documentation for ZDX Signed-off-by: Mitch Kelly --- docsrc/index.rst | 15 ++++++++++++++- docsrc/zs/zdx/admin.rst | 12 ++++++++++++ docsrc/zs/zdx/apps.rst | 12 ++++++++++++ docsrc/zs/zdx/devices.rst | 12 ++++++++++++ docsrc/zs/zdx/index.rst | 13 +++++++++++++ docsrc/zs/zdx/session.rst | 12 ++++++++++++ 6 files changed, 75 insertions(+), 1 deletion(-) create mode 100644 docsrc/zs/zdx/admin.rst create mode 100644 docsrc/zs/zdx/apps.rst create mode 100644 docsrc/zs/zdx/devices.rst create mode 100644 docsrc/zs/zdx/index.rst create mode 100644 docsrc/zs/zdx/session.rst diff --git a/docsrc/index.rst b/docsrc/index.rst index 9eb8bf7..23b0775 100644 --- a/docsrc/index.rst +++ b/docsrc/index.rst @@ -9,6 +9,7 @@ zs/zia/index zs/zpa/index zs/zcc/index + zs/zdx/index pyZscaler SDK - Library Reference ===================================================================== @@ -41,7 +42,7 @@ Products - :doc:`Zscaler Private Access (ZPA) ` - :doc:`Zscaler Internet Access (ZIA) ` - :doc:`Zscaler Mobile Admin Portal ` -- Cloud Security Posture Management (CSPM) - (work in progress) +- :doc:`Zscaler Digital Experience (ZDX) ` Installation ============== @@ -98,6 +99,18 @@ Quick ZCC Example for device in zcc.devices.list_devices(): pprint(device) +Quick ZDX Example +^^^^^^^^^^^^^^^^^^^ + +.. code-block:: python + + from pyzscaler import ZDX + from pprint import pprint + + zcc = ZDX(client_id='CLIENT_ID', client_secret='CLIENT_SECRET') + for device in zdx.devices.list_devices(): + pprint(device) + .. automodule:: pyzscaler :members: diff --git a/docsrc/zs/zdx/admin.rst b/docsrc/zs/zdx/admin.rst new file mode 100644 index 0000000..0ef1182 --- /dev/null +++ b/docsrc/zs/zdx/admin.rst @@ -0,0 +1,12 @@ +admin +------ + +The following methods allow for interaction with the ZDX +Admin API endpoints. + +Methods are accessible via ``zdx.admin`` + +.. _zdx-admin: + +.. automodule:: pyzscaler.zdx.admin + :members: \ No newline at end of file diff --git a/docsrc/zs/zdx/apps.rst b/docsrc/zs/zdx/apps.rst new file mode 100644 index 0000000..380f58d --- /dev/null +++ b/docsrc/zs/zdx/apps.rst @@ -0,0 +1,12 @@ +apps +------ + +The following methods allow for interaction with the ZDX +Application API endpoints. + +Methods are accessible via ``zdx.apps`` + +.. _zdx-apps: + +.. automodule:: pyzscaler.zdx.apps + :members: \ No newline at end of file diff --git a/docsrc/zs/zdx/devices.rst b/docsrc/zs/zdx/devices.rst new file mode 100644 index 0000000..6fd3c2d --- /dev/null +++ b/docsrc/zs/zdx/devices.rst @@ -0,0 +1,12 @@ +devices +------- + +The following methods allow for interaction with the ZDX +Devices API endpoints. + +Methods are accessible via ``zdx.devices`` + +.. _zdx-devices: + +.. automodule:: pyzscaler.zdx.devices + :members: \ No newline at end of file diff --git a/docsrc/zs/zdx/index.rst b/docsrc/zs/zdx/index.rst new file mode 100644 index 0000000..b53823c --- /dev/null +++ b/docsrc/zs/zdx/index.rst @@ -0,0 +1,13 @@ +ZDX +========== +This package covers the ZDX interface. + +.. toctree:: + :maxdepth: 1 + :glob: + :hidden: + + * + +.. automodule:: pyzscaler.zdx + :members: diff --git a/docsrc/zs/zdx/session.rst b/docsrc/zs/zdx/session.rst new file mode 100644 index 0000000..51b4101 --- /dev/null +++ b/docsrc/zs/zdx/session.rst @@ -0,0 +1,12 @@ +session +------- + +The following methods allow for interaction with the ZDX +Session API endpoints. + +Methods are accessible via ``zdx.session`` + +.. _zdx-session: + +.. automodule:: pyzscaler.zdx.session + :members: \ No newline at end of file From 8bfb651d222d67574a9eead769c0e3aaaecbd054 Mon Sep 17 00:00:00 2001 From: Mitch Kelly Date: Tue, 4 Apr 2023 06:13:39 +1000 Subject: [PATCH 21/54] chore: remove print statement used for debugging --- pyzscaler/zdx/__init__.py | 1 - 1 file changed, 1 deletion(-) diff --git a/pyzscaler/zdx/__init__.py b/pyzscaler/zdx/__init__.py index e2f1a7f..8295fd3 100644 --- a/pyzscaler/zdx/__init__.py +++ b/pyzscaler/zdx/__init__.py @@ -71,7 +71,6 @@ def admin(self): @property def apps(self): """The interface object for the :ref:`ZDX Apps interface `.""" - print(f"Headers are: {self._session.headers}") return AppsAPI(self) @property From 9ef8e46988ae52f72a2fb06b30cc9095668d5059 Mon Sep 17 00:00:00 2001 From: Mitch Kelly Date: Thu, 13 Apr 2023 13:35:09 +1000 Subject: [PATCH 22/54] feat: add additional ZDX Admin API endpoints and refactor existing code for uniformity Signed-off-by: Mitch Kelly --- pyzscaler/zdx/admin.py | 57 +++++++++++++++++++++++++++++++++++++++--- 1 file changed, 53 insertions(+), 4 deletions(-) diff --git a/pyzscaler/zdx/admin.py b/pyzscaler/zdx/admin.py index cc76884..f39a09f 100644 --- a/pyzscaler/zdx/admin.py +++ b/pyzscaler/zdx/admin.py @@ -1,21 +1,70 @@ +from box import BoxList from restfly.endpoint import APIEndpoint +from pyzscaler.utils import zdx_params + class AdminAPI(APIEndpoint): - def get_departments(self): + @zdx_params + def list_departments(self, **kwargs) -> BoxList: """ Returns a list of departments that are configured within ZDX. + Keyword Args: + since (int): The number of hours to look back for devices. + search (str): The search string to filter by name or department ID. + Returns: + :obj:`BoxList`: The list of departments in ZDX. + + Examples: + List all departments in ZDX for the past 2 hours + >>> for department in zdx.admin.list_departments(): + ... print(department) """ - return self._get("administration/departments") - def get_locations(self): + return self._get("administration/departments", params=kwargs) + + @zdx_params + def list_locations(self, **kwargs) -> BoxList: + # TODO: Check if the keyword arg is 'search' or 'q'. Docs are potentially wrong or inconsistent """ Returns a list of locations that are configured within ZDX. + Keyword Args: + since (int): The number of hours to look back for devices. + search (str): The search string to filter by name or location ID. + + Returns: + :obj:`BoxList`: The list of locations in ZDX. + + Examples: + List all locations in ZDX for the past 2 hours + >>> for location in zdx.admin.list_locations(): + ... print(location) + + """ + return self._get("administration/locations", params=kwargs) + + @zdx_params + def list_geolocations(self, **kwargs) -> BoxList: + """ + Returns a list of all active geolocations configured within the ZDX tenant. + + Keyword Args: + since (int): The number of hours to look back for devices. + location_id (str): The unique ID for the location. + parent_geo_id (str): The unique ID for the parent geolocation. + search (str): The search string to filter by name. + Returns: + :obj:`BoxList`: The list of geolocations in ZDX. + + Examples: + List all geolocations in ZDX for the past 2 hours + >>> for geolocation in zdx.admin.list_geolocations(): + ... print(geolocation) """ - return self._get("administration/locations") + return self._get("active_geo", params=kwargs) From 99debecbbc1ef684938a4edcc3ca74d13be9ddda Mon Sep 17 00:00:00 2001 From: Mitch Kelly Date: Thu, 13 Apr 2023 13:35:53 +1000 Subject: [PATCH 23/54] tests: add tests for ZDX Admin API endpoints Signed-off-by: Mitch Kelly --- tests/zdx/test_zdx_admin.py | 83 +++++++++++++++++++++++++++++++++++++ 1 file changed, 83 insertions(+) create mode 100644 tests/zdx/test_zdx_admin.py diff --git a/tests/zdx/test_zdx_admin.py b/tests/zdx/test_zdx_admin.py new file mode 100644 index 0000000..f3e5bdc --- /dev/null +++ b/tests/zdx/test_zdx_admin.py @@ -0,0 +1,83 @@ +import responses +from box import BoxList + +from pyzscaler.utils import calculate_epoch + + +@responses.activate +def test_list_geolocations(zdx): + # set up the mock response + + mock_response = [ + { + "id": "1", + "name": "geolocation1", + "geo_type": "region", + "children": [{"id": "11", "description": "child geolocation1", "geo_type": "country"}], + }, + { + "id": "2", + "name": "geolocation2", + "geo_type": "region", + "children": [{"id": "21", "description": "child geolocation2", "geo_type": "country"}], + }, + ] + current, past = calculate_epoch(2) + url = "https://api.zdxcloud.net/v1/active_geo" + responses.add(responses.GET, url, json=mock_response, status=200) + + # call the method + result = zdx.admin.list_geolocations() + + # assert the response is correct + assert isinstance(result, BoxList) + assert len(result) == 2 + assert result[0]["id"] == "1" + assert result[1]["id"] == "2" + + # assert the request is correct + request = responses.calls[0].request + assert request.url == url + assert request.method == "GET" + + +@responses.activate +def test_list_departments(zdx): + url = "https://api.zdxcloud.net/v1/administration/departments" + mock_response = [{"id": "1", "name": "department1"}, {"id": "2", "name": "department2"}] + responses.add(responses.GET, url, json=mock_response, status=200) + + # call the method + result = zdx.admin.list_departments() + + # assert the response is correct + assert isinstance(result, BoxList) + assert len(result) == 2 + assert result[0]["id"] == "1" + assert result[1]["id"] == "2" + + # assert the request is correct + request = responses.calls[0].request + assert request.url == url + assert request.method == "GET" + + +@responses.activate +def test_list_locations(zdx): + url = "https://api.zdxcloud.net/v1/administration/locations" + mock_response = [{"id": "1", "name": "location1"}, {"id": "2", "name": "location2"}] + responses.add(responses.GET, url, json=mock_response, status=200) + + # call the method + result = zdx.admin.list_locations() + + # assert the response is correct + assert isinstance(result, BoxList) + assert len(result) == 2 + assert result[0]["id"] == "1" + assert result[1]["id"] == "2" + + # assert the request is correct + request = responses.calls[0].request + assert request.url == url + assert request.method == "GET" From 430882f6ad21eebfd3ccb4e2413fb3cfadb1ad0b Mon Sep 17 00:00:00 2001 From: Mitch Kelly Date: Thu, 20 Apr 2023 04:54:11 +1000 Subject: [PATCH 24/54] feat: add complete functionality for ZDX devices API endpoints Signed-off-by: Mitch Kelly --- pyzscaler/zdx/devices.py | 211 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 211 insertions(+) diff --git a/pyzscaler/zdx/devices.py b/pyzscaler/zdx/devices.py index 9c7e8c6..6da9979 100644 --- a/pyzscaler/zdx/devices.py +++ b/pyzscaler/zdx/devices.py @@ -284,3 +284,214 @@ def get_events(self, device_id: str): """ return self._get(f"devices/{device_id}/events") + + def list_deeptraces(self, device_id: str): + """ + Returns a list of all deep traces for a specific device. + + Args: + device_id (str): The unique ID for the device. + + Returns: + :obj:`BoxList`: The list of deep traces for the device. + + Examples: + Print a list of deep traces for a device. + + >>> for trace in zdx.devices.list_deep_traces('123456789'): + ... print(trace) + + """ + return self._get(f"devices/{device_id}/deeptraces") + + def start_deeptrace(self, device_id: str, app_id: str, session_name: str, **kwargs): + """ + Starts a deep trace for a specific device and application. + + Args: + device_id (str): The unique ID for the device. + app_id (str): The unique ID for the application. + session_name (str): The name of the deeptrace session. + + Keyword Args: + web_probe_id (str): The unique ID for the Web probe. + cloudpath_probe_id (str): The unique ID for the Cloudpath probe. + session_length_minutes (int): The duration of the deeptrace session in minutes. Defaults to 5. + probe_device (bool): Whether to probe the device. + + Returns: + :obj:`Box`: The deeptrace resource record. + + Examples: + Start a deeptrace for a device. + + >>> trace = zdx.devices.start_deeptrace(device_id='123456789', app_id='1', session_name='My Deeptrace') + ... print(trace) + + """ + payload = { + "session_name": session_name, + "app_id": app_id, + } | kwargs + + return self._post(f"devices/{device_id}/deeptraces", json=payload) + + def get_deeptrace(self, device_id: str, trace_id: str): + """ + Returns information on a single deeptrace for a specific device. + + Args: + device_id (str): The unique ID for the device. + trace_id (str): The unique ID for the deeptrace. + + Returns: + :obj:`Box`: The deeptrace resource record. + + Examples: + Print a single deeptrace for a device. + + >>> trace = zdx.devices.get_deeptrace('123456789', '987654321') + ... print(trace) + + """ + return self._get(f"devices/{device_id}/deeptraces/{trace_id}") + + def delete_deeptrace(self, device_id: str, trace_id: str): + """ + Deletes a single deeptrace session and associated data for a specific device. + + Args: + device_id (str): The unique ID for the device. + trace_id (str): The unique ID for the deeptrace. + + Returns: + :obj:`str`: The trace ID that was deleted. + + Examples: + Delete a single deeptrace for a device. + + >>> trace = zdx.devices.delete_deeptrace('123456789', '987654321') + ... print(trace) + + """ + return self._delete(f"devices/{device_id}/deeptraces/{trace_id}") + + def get_deeptrace_webprobe_metrics(self, device_id: str, trace_id: str): + """ + Returns web probe metrics for a specific deeptrace. + + Args: + device_id (str): The unique ID for the device. + trace_id (str): The unique ID for the deeptrace. + + Returns: + :obj:`Box`: The deeptrace web probe metrics. + + Examples: + Print web probe metrics for a deeptrace. + + >>> metrics = zdx.devices.get_deeptrace_webprobe_metrics('123456789', '987654321') + ... print(metrics) + + """ + return self._get(f"devices/{device_id}/deeptraces/{trace_id}/webprobe-metrics") + + def get_deeptrace_cloudpath_metrics(self, device_id: str, trace_id: str): + """ + Returns cloudpath metrics for a specific deeptrace. + + Args: + device_id (str): The unique ID for the device. + trace_id (str): The unique ID for the deeptrace. + + Returns: + :obj:`Box`: The deeptrace cloudpath metrics. + + Examples: + Print cloudpath metrics for a deeptrace. + + >>> metrics = zdx.devices.get_deeptrace_cloudpath_metrics('123456789', '987654321') + ... print(metrics) + + """ + return self._get(f"devices/{device_id}/deeptraces/{trace_id}/cloudpath-metrics") + + def get_deeptrace_cloudpath(self, device_id: str, trace_id: str): + """ + Returns cloudpath for a specific deeptrace. + + Args: + device_id (str): The unique ID for the device. + trace_id (str): The unique ID for the deeptrace. + + Returns: + :obj:`Box`: The deeptrace cloudpath. + + Examples: + Print cloudpath for a deeptrace. + + >>> metrics = zdx.devices.get_deeptrace_cloudpath('123456789', '987654321') + ... print(metrics) + + """ + return self._get(f"devices/{device_id}/deeptraces/{trace_id}/cloudpath") + + def get_deeptrace_health_metrics(self, device_id: str, trace_id: str): + """ + Returns health metrics for a specific deeptrace. + + Args: + device_id (str): The unique ID for the device. + trace_id (str): The unique ID for the deeptrace. + + Returns: + :obj:`Box`: The deeptrace health metrics. + + Examples: + Print health metrics for a deeptrace. + + >>> metrics = zdx.devices.get_deeptrace_health_metrics('123456789', '987654321') + ... print(metrics) + + """ + return self._get(f"devices/{device_id}/deeptraces/{trace_id}/health-metrics") + + def get_deeptrace_events(self, device_id: str, trace_id: str): + """ + Returns events for a specific deeptrace. + + Args: + device_id (str): The unique ID for the device. + trace_id (str): The unique ID for the deeptrace. + + Returns: + :obj:`Box`: The deeptrace events. + + Examples: + Print events for a deeptrace. + + >>> events = zdx.devices.get_deeptrace_events('123456789', '987654321') + ... print(events) + + """ + return self._get(f"devices/{device_id}/deeptraces/{trace_id}/events") + + def get_deeptrace_top_processes(self, device_id: str, trace_id: str): + """ + Returns top processes for a specific deeptrace. + + Args: + device_id (str): The unique ID for the device. + trace_id (str): The unique ID for the deeptrace. + + Returns: + :obj:`Box`: The deeptrace top processes. + + Examples: + Print top processes for a deeptrace. + + >>> top_processes = zdx.devices.get_deeptrace_top_processes('123456789', '987654321') + ... print(top_processes) + + """ + return self._get(f"devices/{device_id}/deeptraces/{trace_id}/top-processes") From 893665fcb26d353da3d67706485504476018e4e5 Mon Sep 17 00:00:00 2001 From: Mitch Kelly Date: Fri, 2 Jun 2023 14:45:24 +1000 Subject: [PATCH 25/54] test: add additional endpoints to ZDX test suite Signed-off-by: Mitch Kelly --- tests/test_utils.py | 18 ++ tests/zdx/conftest.py | 28 ++ tests/zdx/test_zdx_apps.py | 186 ++++++++++++++ tests/zdx/test_zdx_devices.py | 469 ++++++++++++++++++++++++++++++++++ tests/zdx/test_zdx_session.py | 47 ++++ tests/zdx/test_zdx_users.py | 50 ++++ 6 files changed, 798 insertions(+) create mode 100644 tests/test_utils.py create mode 100644 tests/zdx/conftest.py create mode 100644 tests/zdx/test_zdx_apps.py create mode 100644 tests/zdx/test_zdx_devices.py create mode 100644 tests/zdx/test_zdx_session.py create mode 100644 tests/zdx/test_zdx_users.py diff --git a/tests/test_utils.py b/tests/test_utils.py new file mode 100644 index 0000000..120542c --- /dev/null +++ b/tests/test_utils.py @@ -0,0 +1,18 @@ +from pyzscaler.utils import zdx_params + + +def test_zdx_params(): + @zdx_params + def dummy_function(self, **kwargs): + return kwargs + + result = dummy_function( + None, since=10, search="test_search", location_id="test_loc", department_id="test_dept", geo_id="test_geo" + ) + + assert result["to"] is not None + assert result["from"] is not None + assert result["q"] == "test_search" + assert result["loc"] == "test_loc" + assert result["dept"] == "test_dept" + assert result["geo"] == "test_geo" diff --git a/tests/zdx/conftest.py b/tests/zdx/conftest.py new file mode 100644 index 0000000..da31311 --- /dev/null +++ b/tests/zdx/conftest.py @@ -0,0 +1,28 @@ +import pytest +import responses + +from pyzscaler.zdx import ZDX + + +@pytest.fixture(name="session") +def fixture_session(): + return { + "token": "ADMIN_LOGIN", + } + + +@pytest.fixture(name="zdx") +@responses.activate +def zdx(session): + responses.add( + responses.POST, + url="https://api.zdxcloud.net/v1/oauth/token", + content_type="application/json", + json=session, + status=200, + ) + + return ZDX( + client_id="abc123", + client_secret="999999", + ) diff --git a/tests/zdx/test_zdx_apps.py b/tests/zdx/test_zdx_apps.py new file mode 100644 index 0000000..205abb5 --- /dev/null +++ b/tests/zdx/test_zdx_apps.py @@ -0,0 +1,186 @@ +import responses +from box import Box, BoxList + +from tests.conftest import stub_sleep + + +@responses.activate +def test_list_apps(zdx): + url = "https://api.zdxcloud.net/v1/apps" + mock_response = [{"id": "1", "name": "app1"}, {"id": "2", "name": "app2"}] + responses.add(responses.GET, url, json=mock_response, status=200) + + result = zdx.apps.list_apps() + + assert isinstance(result, BoxList) + assert len(result) == 2 + assert result[0]["id"] == "1" + assert result[1]["id"] == "2" + + request = responses.calls[0].request + assert request.url == url + assert request.method == "GET" + + +@responses.activate +def test_get_app(zdx): + app_id = "1" + url = f"https://api.zdxcloud.net/v1/apps/{app_id}" + mock_response = {"id": "1", "name": "app1"} + responses.add(responses.GET, url, json=mock_response, status=200) + + result = zdx.apps.get_app(app_id) + + assert isinstance(result, Box) + assert result["id"] == "1" + assert result["name"] == "app1" + + request = responses.calls[0].request + assert request.url == url + assert request.method == "GET" + + +@responses.activate +def test_get_app_score(zdx): + app_id = "1" + url = f"https://api.zdxcloud.net/v1/apps/{app_id}/score" + mock_response = { + "metric": "score", + "datapoints": [ + {"timestamp": 1644163200, "value": 80}, + {"timestamp": 1644163500, "value": 75}, + ], + } + responses.add(responses.GET, url, json=mock_response, status=200) + + result = zdx.apps.get_app_score(app_id) + + assert isinstance(result, Box) + assert result["metric"] == "score" + assert len(result["datapoints"]) == 2 + + request = responses.calls[0].request + assert request.url == url + assert request.method == "GET" + + +@responses.activate +def test_get_app_metrics(zdx): + app_id = "1" + url = f"https://api.zdxcloud.net/v1/apps/{app_id}/metrics" + mock_response = { + "metric": "metricName", + "unit": "metricUnit", + "datapoints": [ + {"timestamp": 1644163200, "value": 100}, + {"timestamp": 1644163500, "value": 90}, + ], + } + responses.add(responses.GET, url, json=mock_response, status=200) + + result = zdx.apps.get_app_metrics(app_id) + + assert isinstance(result, Box) + assert result["metric"] == "metricName" + assert result["unit"] == "metricUnit" + assert len(result["datapoints"]) == 2 + + request = responses.calls[0].request + assert request.url == url + assert request.method == "GET" + + +@responses.activate +@stub_sleep +def test_list_app_users(zdx): + app_id = "1" + url = f"https://api.zdxcloud.net/v1/apps/{app_id}/users" + mock_response = { + "users": [ + {"id": "1", "name": "user1", "email": "user1@example.com", "score": 80}, + {"id": "2", "name": "user2", "email": "user2@example.com", "score": 90}, + ], + "next_offset": None, + } + responses.add(responses.GET, url, json=mock_response, status=200) + + result = zdx.apps.list_app_users(app_id) + + assert isinstance(result, BoxList) + assert len(result) == 2 + assert result[0]["id"] == "1" + assert result[1]["id"] == "2" + + request = responses.calls[0].request + assert request.url == url + assert request.method == "GET" + + +@responses.activate +@stub_sleep +def test_list_app_users_multipage(zdx): + app_id = "1" + url = f"https://api.zdxcloud.net/v1/apps/{app_id}/users" + url_with_offset = f"https://api.zdxcloud.net/v1/apps/{app_id}/users?offset=2" + + # First page response + mock_response_1 = { + "users": [ + {"id": "1", "name": "user1", "email": "user1@example.com", "score": 80}, + {"id": "2", "name": "user2", "email": "user2@example.com", "score": 90}, + ], + "next_offset": "2", + } + responses.add(responses.GET, url, json=mock_response_1, status=200) + + # Second page response + mock_response_2 = { + "users": [ + {"id": "3", "name": "user3", "email": "user3@example.com", "score": 70}, + {"id": "4", "name": "user4", "email": "user4@example.com", "score": 60}, + ], + "next_offset": None, # Signifying no more pages + } + responses.add(responses.GET, url_with_offset, json=mock_response_2, status=200) + + result = zdx.apps.list_app_users(app_id) + + assert isinstance(result, BoxList) + assert len(result) == 4 # Total of 4 users across 2 pages + assert result[0]["id"] == "1" + assert result[1]["id"] == "2" + assert result[2]["id"] == "3" + assert result[3]["id"] == "4" + + # Check the first API request + request1 = responses.calls[0].request + assert request1.url == url + assert request1.method == "GET" + + # Check the second API request + request2 = responses.calls[1].request + assert request2.url == url_with_offset + + +@responses.activate +def test_get_app_user(zdx): + app_id = "1" + user_id = "1" + url = f"https://api.zdxcloud.net/v1/apps/{app_id}/users/{user_id}" + since = 10 + mock_response = { + "user": {"id": "1", "name": "user1", "email": "user1@example.com", "score": 80}, + "device": {"id": "device1", "model": "iPhone", "os": "iOS"}, + } + responses.add(responses.GET, url, json=mock_response, status=200) + + result = zdx.apps.get_app_user(app_id, user_id, since=since) + + assert isinstance(result, Box) + assert result.user.id == mock_response["user"]["id"] + assert result.user.name == mock_response["user"]["name"] + assert result.user.email == mock_response["user"]["email"] + assert result.user.score == mock_response["user"]["score"] + assert result.device.id == mock_response["device"]["id"] + assert result.device.model == mock_response["device"]["model"] + assert result.device.os == mock_response["device"]["os"] diff --git a/tests/zdx/test_zdx_devices.py b/tests/zdx/test_zdx_devices.py new file mode 100644 index 0000000..11be732 --- /dev/null +++ b/tests/zdx/test_zdx_devices.py @@ -0,0 +1,469 @@ +import responses +from box import Box, BoxList + + +@responses.activate +def test_list_devices(zdx): + url = "https://api.zdxcloud.net/v1/devices" + mock_response = { + "devices": [ + {"id": 40176154, "name": "LAPTOP-DQG97O6G (LENOVO 20WNS1HM01 Microsoft Windows 10 Pro;64 bit)", "userid": 76676623} + ], + "next_offset": "67677666", + } + responses.add(responses.GET, url, json=mock_response, status=200) + + result = zdx.devices.list_devices() + + assert isinstance(result, Box) + assert result.devices[0].id == mock_response["devices"][0]["id"] + + +@responses.activate +def test_get_device(zdx): + device_id = "30989301" + url = f"https://api.zdxcloud.net/v1/devices/{device_id}" + mock_response = { # your large mock response here, truncated for brevity + "id": 30989301, + "name": "LAPTOP-S1IN4SIH (LENOVO 20T1S0Q303 Microsoft Windows 10 Pro;64 bit)", + # More fields... + } + responses.add(responses.GET, url, json=mock_response, status=200) + + result = zdx.devices.get_device(device_id) + + assert isinstance(result, Box) + assert result.id == mock_response["id"] + assert result.name == mock_response["name"] + + +@responses.activate +def test_get_device_apps(zdx): + device_id = "123456789" + url = f"https://api.zdxcloud.net/v1/devices/{device_id}/apps" + mock_response = [{"id": 1, "name": "Sharepoint", "score": 81}, {"id": 4, "name": "Salesforce", "score": 90}] + responses.add(responses.GET, url, json=mock_response, status=200) + + result = zdx.devices.get_device_apps(device_id) + + assert isinstance(result, BoxList) + assert len(result) == len(mock_response) + for res, expected in zip(result, mock_response): + assert res == Box(expected) + + +@responses.activate +def test_get_device_app(zdx): + device_id = "123456789" + app_id = "987654321" + url = f"https://api.zdxcloud.net/v1/devices/{device_id}/apps/{app_id}" + mock_response = { + "metric": "score", + "datapoints": [ + {"timestamp": 1644163200, "value": 80}, + ], + } + responses.add(responses.GET, url, json=mock_response, status=200) + + result = zdx.devices.get_device_app(device_id, app_id) + + assert isinstance(result, Box) + assert result == Box(mock_response) + + +@responses.activate +def test_get_web_probes(zdx): + device_id = "123456789" + app_id = "987654321" + url = f"https://api.zdxcloud.net/v1/devices/{device_id}/apps/{app_id}/web-probes" + mock_response = [{"id": 4, "name": "Outlook Online Login Page Probe", "num_probes": 24, "avg_score": 85, "avg_pft": 2340}] + responses.add(responses.GET, url, json=mock_response, status=200) + + result = zdx.devices.get_web_probes(device_id, app_id) + + assert isinstance(result, BoxList) + assert len(result) == len(mock_response) + for res, expected in zip(result, mock_response): + assert res == Box(expected) + + +@responses.activate +def test_get_web_probe(zdx): + device_id = "123456789" + app_id = "987654321" + probe_id = "123987456" + url = f"https://api.zdxcloud.net/v1/devices/{device_id}/apps/{app_id}/web-probes/{probe_id}" + mock_response = {"metric": "string", "unit": "string", "datapoints": [{"timestamp": 0, "value": 0}]} + responses.add(responses.GET, url, json=mock_response, status=200) + + result = zdx.devices.get_web_probe(device_id, app_id, probe_id) + + assert isinstance(result, Box) + assert result == Box(mock_response) + + +@responses.activate +def test_list_cloudpath_probes(zdx): + device_id = "123456789" + app_id = "987654321" + url = f"https://api.zdxcloud.net/v1/devices/{device_id}/apps/{app_id}/cloudpath-probes" + mock_response = [ + { + "id": 4, + "name": "Outlook Online CloudPath Probe", + "num_probes": 12, + "avg_latencies": [ + {"leg_src": "client", "leg_dst": "egress", "latency": 15}, + {"leg_src": "egress", "leg_dst": "zen", "latency": 34}, + ], + } + ] + responses.add(responses.GET, url, json=mock_response, status=200) + + result = zdx.devices.list_cloudpath_probes(device_id, app_id) + + assert isinstance(result, BoxList) + assert len(result) == len(mock_response) + for res, expected in zip(result, mock_response): + assert res == Box(expected) + + +@responses.activate +def test_get_cloudpath_probe(zdx): + device_id = "123456789" + app_id = "987654321" + probe_id = "123987456" + url = f"https://api.zdxcloud.net/v1/devices/{device_id}/apps/{app_id}/cloudpath-probes/{probe_id}" + mock_response = { + "leg_src": "string", + "leg_dst": "string", + "stats": [{"metric": "string", "unit": "string", "datapoints": [{"timestamp": 0, "value": 0}]}], + } + responses.add(responses.GET, url, json=mock_response, status=200) + + result = zdx.devices.get_cloudpath_probe(device_id, app_id, probe_id) + + assert isinstance(result, Box) + assert result == Box(mock_response) + + +@responses.activate +def test_get_cloudpath(zdx): + device_id = "123456789" + app_id = "987654321" + probe_id = "123987456" + url = f"https://api.zdxcloud.net/v1/devices/{device_id}/apps/{app_id}/cloudpath-probes/{probe_id}/cloudpath" + mock_response = { + "timestamp": 0, + "cloudpath": { + "src": "string", + "dst": "string", + "num_hops": 0, + "latency": 0, + }, + } + responses.add(responses.GET, url, json=mock_response, status=200) + + result = zdx.devices.get_cloudpath(device_id, app_id, probe_id) + + assert isinstance(result, Box) + assert result == Box(mock_response) + + +@responses.activate +def test_get_call_quality_metrics(zdx): + device_id = "123456789" + app_id = "987654321" + url = f"https://api.zdxcloud.net/v1/devices/{device_id}/apps/{app_id}/call-quality-metrics" + mock_response = { + "meet_id": "string", + "meet_session_id": "string", + "meet_subject": "string", + "metrics": [{"metric": "string", "unit": "string", "datapoints": [{"timestamp": 0, "value": 0}]}], + } + responses.add(responses.GET, url, json=mock_response, status=200) + + result = zdx.devices.get_call_quality_metrics(device_id, app_id) + + assert isinstance(result, Box) + assert result == Box(mock_response) + + +@responses.activate +def test_get_health_metrics(zdx): + device_id = "123456789" + url = f"https://api.zdxcloud.net/v1/devices/{device_id}/health-metrics" + mock_response = { + "category": "string", + "instances": [ + { + "name": "string", + "metrics": [{"metric": "string", "unit": "string", "datapoints": [{"timestamp": 0, "value": 0}]}], + } + ], + } + responses.add(responses.GET, url, json=mock_response, status=200) + + result = zdx.devices.get_health_metrics(device_id) + + assert isinstance(result, Box) + assert result == Box(mock_response) + + +@responses.activate +def test_get_events(zdx): + device_id = "123456789" + url = f"https://api.zdxcloud.net/v1/devices/{device_id}/events" + mock_response = [ + { + "timestamp": 1643525900, + "events": [ + {"category": "Zscaler", "name": "tunType", "display_name": "Tunnel Change", "prev": "0", "curr": "3"}, + ], + }, + ] + responses.add(responses.GET, url, json=mock_response, status=200) + + result = zdx.devices.get_events(device_id) + + assert isinstance(result, BoxList) + assert len(result) == len(mock_response) + for res, expected in zip(result, mock_response): + assert res == Box(expected) + + +@responses.activate +def test_list_deeptraces(zdx): + device_id = "123456789" + url = f"https://api.zdxcloud.net/v1/devices/{device_id}/deeptraces" + mock_response = [ + { + "trace_id": 0, + "trace_details": { + "session_name": "string", + "user_id": 0, + "username": "string", + "device_id": 0, + "device_name": "string", + "web_probe_id": 0, + "web_probe_name": "string", + }, + "status": "not_started", + "created_at": 0, + "started_at": 0, + "ended_at": 0, + }, + ] + responses.add(responses.GET, url, json=mock_response, status=200) + + result = zdx.devices.list_deeptraces(device_id) + + assert isinstance(result, BoxList) + assert len(result) == len(mock_response) + for res, expected in zip(result, mock_response): + assert res == Box(expected) + + +@responses.activate +def test_start_deeptrace(zdx): + device_id = "123456789" + app_id = "987654321" + session_name = "My Deeptrace" + url = f"https://api.zdxcloud.net/v1/devices/{device_id}/deeptraces" + mock_response = {"trace_id": 0, "status": "not_started", "expected_time": 0} + responses.add(responses.POST, url, json=mock_response, status=200) + + result = zdx.devices.start_deeptrace(device_id, app_id, session_name) + + assert isinstance(result, Box) + assert result == Box(mock_response) + + +@responses.activate +def test_get_deeptrace(zdx): + device_id = "123456789" + trace_id = "987654321" + url = f"https://api.zdxcloud.net/v1/devices/{device_id}/deeptraces/{trace_id}" + mock_response = { + "trace_id": 0, + "trace_details": { + "session_name": "string", + "user_id": 0, + "username": "string", + "device_id": 0, + "device_name": "string", + "web_probe_id": 0, + "web_probe_name": "string", + }, + "status": "not_started", + "created_at": 0, + "started_at": 0, + "ended_at": 0, + } + responses.add(responses.GET, url, json=mock_response, status=200) + + result = zdx.devices.get_deeptrace(device_id, trace_id) + + assert isinstance(result, Box) + assert result == Box(mock_response) + + +@responses.activate +def test_delete_deeptrace(zdx): + device_id = "123456789" + trace_id = "987654321" + url = f"https://api.zdxcloud.net/v1/devices/{device_id}/deeptraces/{trace_id}" + mock_response = {"trace_id": 0} + responses.add(responses.DELETE, url, json=mock_response, status=200) + + result = zdx.devices.delete_deeptrace(device_id, trace_id) + + assert isinstance(result.trace_id, int) + assert result == (mock_response) + + +@responses.activate +def test_get_deeptrace_webprobe_metrics(zdx): + device_id = "123456789" + trace_id = "987654321" + url = f"https://api.zdxcloud.net/v1/devices/{device_id}/deeptraces/{trace_id}/webprobe-metrics" + mock_response = {"metric": "string", "unit": "string", "datapoints": [{"timestamp": 0, "value": 0}]} + responses.add(responses.GET, url, json=mock_response, status=200) + + result = zdx.devices.get_deeptrace_webprobe_metrics(device_id, trace_id) + + assert isinstance(result, Box) + assert result == Box(mock_response) + + +@responses.activate +def test_get_deeptrace_cloudpath_metrics(zdx): + device_id = "123456789" + trace_id = "987654321" + url = f"https://api.zdxcloud.net/v1/devices/{device_id}/deeptraces/{trace_id}/cloudpath-metrics" + mock_response = { + "leg_src": "string", + "leg_dst": "string", + "stats": [{"metric": "string", "unit": "string", "datapoints": [{"timestamp": 0, "value": 0}]}], + } + responses.add(responses.GET, url, json=mock_response, status=200) + + result = zdx.devices.get_deeptrace_cloudpath_metrics(device_id, trace_id) + + assert isinstance(result, Box) + assert result == Box(mock_response) + + +@responses.activate +def test_get_deeptrace_cloudpath(zdx): + device_id = "123456789" + trace_id = "987654321" + url = f"https://api.zdxcloud.net/v1/devices/{device_id}/deeptraces/{trace_id}/cloudpath" + mock_response = { + "timestamp": 0, + "cloudpath": { + "src": "string", + "dst": "string", + "num_hops": 0, + "latency": 0, + "loss": 0, + "num_unresp_hops": 0, + "tunnel_type": 0, + "hops": [ + { + "ip": "string", + "gw_mac": "string", + "gw_mac_vendor": "string", + "pkt_sent": 0, + "pkt_rcvd": 0, + "latency_min": 0, + "latency_max": 0, + "latency_avg": 0, + "latency_diff": 0, + } + ], + }, + } + responses.add(responses.GET, url, json=mock_response, status=200) + + result = zdx.devices.get_deeptrace_cloudpath(device_id, trace_id) + + assert isinstance(result, Box) + assert result == Box(mock_response) + + +@responses.activate +def test_get_deeptrace_health_metrics(zdx): + device_id = "123456789" + trace_id = "987654321" + url = f"https://api.zdxcloud.net/v1/devices/{device_id}/deeptraces/{trace_id}/health-metrics" + mock_response = { + "category": "string", + "instances": [ + { + "name": "string", + "metrics": [{"metric": "string", "unit": "string", "datapoints": [{"timestamp": 0, "value": 0}]}], + } + ], + } + responses.add(responses.GET, url, json=mock_response, status=200) + + result = zdx.devices.get_deeptrace_health_metrics(device_id, trace_id) + + assert isinstance(result, Box) + assert result == Box(mock_response) + + +@responses.activate +def test_get_deeptrace_events(zdx): + device_id = "123456789" + trace_id = "987654321" + url = f"https://api.zdxcloud.net/v1/devices/{device_id}/deeptraces/{trace_id}/events" + mock_response = [ + { + "timestamp": 1643525900, + "events": [ + { + "category": "Zscaler", + "name": "tunType", + }, + { + "category": "Zscaler", + "name": "ziaState", + }, + ], + }, + { + "timestamp": 1643526200, + "events": [ + { + "category": "Zscaler", + "name": "tunType", + }, + { + "category": "Zscaler", + "name": "ziaState", + }, + ], + }, + ] + responses.add(responses.GET, url, json=mock_response, status=200) + + result = zdx.devices.get_deeptrace_events(device_id, trace_id) + + assert isinstance(result, list) + assert result == BoxList(mock_response) + + +@responses.activate +def test_get_deeptrace_top_processes(zdx): + device_id = "123456789" + trace_id = "987654321" + url = f"https://api.zdxcloud.net/v1/devices/{device_id}/deeptraces/{trace_id}/top-processes" + mock_response = {"timestamp": 0, "top_processes": [{"category": "string", "processes": [{"name": "string", "id": 0}]}]} + responses.add(responses.GET, url, json=mock_response, status=200) + + result = zdx.devices.get_deeptrace_top_processes(device_id, trace_id) + + assert isinstance(result, Box) + assert result == Box(mock_response) diff --git a/tests/zdx/test_zdx_session.py b/tests/zdx/test_zdx_session.py new file mode 100644 index 0000000..5d9a756 --- /dev/null +++ b/tests/zdx/test_zdx_session.py @@ -0,0 +1,47 @@ +import responses +from box import Box + +from tests.conftest import stub_sleep + + +@responses.activate +def test_create_token(zdx): + client_id = "999999999" + client_secret = "admin@example.com" + url = "https://api.zdxcloud.net/v1/oauth/token" + mock_response = {"token": "test_token", "token_type": "Bearer", "expires_in": 3600} + responses.add(responses.POST, url, json=mock_response, status=200) + + result = zdx.session.create_token(client_id, client_secret) + + assert isinstance(result, Box) + assert result.token == mock_response["token"] + assert result.token_type == mock_response["token_type"] + assert result.expires_in == mock_response["expires_in"] + + +@responses.activate +@stub_sleep +def test_validate_token(zdx): + url = "https://api.zdxcloud.net/v1/oauth/validate" + mock_response = {"valid": True} + responses.add(responses.GET, url, json=mock_response, status=200) + + result = zdx.session.validate_token() + + assert isinstance(result, Box) + assert result.valid == mock_response["valid"] + + +@responses.activate +def test_get_jwks(zdx): + url = "https://api.zdxcloud.net/v1/oauth/jwks" + mock_response = { + "keys": [{"alg": "RS256", "kty": "RSA", "use": "sig", "x5c": ["test_string"], "kid": "test_kid", "x5t": "test_x5t"}] + } + responses.add(responses.GET, url, json=mock_response, status=200) + + result = zdx.session.get_jwks() + + assert isinstance(result, Box) + assert result.get("keys")[0].get("alg") == mock_response["keys"][0]["alg"] diff --git a/tests/zdx/test_zdx_users.py b/tests/zdx/test_zdx_users.py new file mode 100644 index 0000000..143b134 --- /dev/null +++ b/tests/zdx/test_zdx_users.py @@ -0,0 +1,50 @@ +import responses +from box import Box, BoxList + + +@responses.activate +def test_list_users(zdx): + url = "https://api.zdxcloud.net/v1/users" + mock_response = {"users": [{"id": 0, "name": "string", "email": "string"}], "next_offset": None} + responses.add(responses.GET, url, json=mock_response, status=200) + + result = zdx.users.list_users() + + assert isinstance(result, BoxList) + assert result == BoxList(mock_response["users"]) + + +@responses.activate +def test_get_user(zdx): + user_id = "999999999" + url = f"https://api.zdxcloud.net/v1/users/{user_id}" + mock_response = { + "id": 0, + "name": "string", + "email": "string", + "devices": [ + { + "id": 0, + "name": "string", + "geo_loc": [ + { + "id": "string", + "city": "string", + "state": "string", + "country": "string", + "geo_type": "string", + "geo_lat": "string", + "geo_long": "string", + "geo_detection": "string", + } + ], + "zs_loc": [{"id": 0, "name": "string"}], + } + ], + } + responses.add(responses.GET, url, json=mock_response, status=200) + + result = zdx.users.get_user(user_id) + + assert isinstance(result, Box) + assert result == Box(mock_response) From e3f7f6d9687d0c449916129be36f82bc7f311447 Mon Sep 17 00:00:00 2001 From: Mitch Kelly Date: Fri, 2 Jun 2023 14:51:06 +1000 Subject: [PATCH 26/54] feat: complete implementation of ZDX Apps API endpoints Signed-off-by: Mitch Kelly --- pyzscaler/zdx/apps.py | 155 ++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 151 insertions(+), 4 deletions(-) diff --git a/pyzscaler/zdx/apps.py b/pyzscaler/zdx/apps.py index d2e90ff..0ba152b 100644 --- a/pyzscaler/zdx/apps.py +++ b/pyzscaler/zdx/apps.py @@ -1,23 +1,170 @@ +from box import BoxList from restfly.endpoint import APIEndpoint +from pyzscaler.utils import ZDXIterator, zdx_params + class AppsAPI(APIEndpoint): - def list_apps(self): + @zdx_params + def list_apps(self, **kwargs) -> BoxList: """ Returns a list of all active applications configured within the ZDX tenant. + Keyword Args: + since (int): The number of hours to look back for devices. + location_id (str): The unique ID for the location. + department_id (str): The unique ID for the department. + geo_id (str): The unique ID for the geolocation. + Returns: + :obj:`BoxList`: The list of applications in ZDX. + + Examples: + List all applications in ZDX for the past 2 hours + >>> for app in zdx.apps.list_apps(): + ... print(app) """ - return self._get("apps") + return self._get("apps", params=kwargs) - def get_app(self, app_id: str): + @zdx_params + def get_app(self, app_id: str, **kwargs): """ Returns information on the specified application configured within the ZDX tenant. + + Args: + app_id (str): The unique ID for the ZDX application. + + Keyword Args: + since (int): The number of hours to look back for devices. + location_id (str): The unique ID for the location. + department_id (str): The unique ID for the department. + geo_id (str): The unique ID for the geolocation. + + Returns: + :obj:`Box`: The application information. + + Examples: + Return information on the application with the ID of 999999999: + >>> zia.apps.get_app(app_id='999999999') + + """ + return self._get(f"apps/{app_id}", params=kwargs) + + @zdx_params + def get_app_score(self, app_id: str, **kwargs): + """ + Returns the ZDX score trend for the specified application configured within the ZDX tenant. + Args: app_id (str): The unique ID for the ZDX application. + Keyword Args: + since (int): The number of hours to look back for devices. + location_id (str): The unique ID for the location. + department_id (str): The unique ID for the department. + geo_id (str): The unique ID for the geolocation. + Returns: + :obj:`Box`: The application's ZDX score trend. + + Examples: + Return the ZDX score trend for the application with the ID of 999999999: + >>> zia.apps.get_app_score(app_id='999999999') + + """ + return self._get(f"apps/{app_id}/score", params=kwargs) + + @zdx_params + def get_app_metrics(self, app_id: str, **kwargs): + """ + Returns the ZDX metrics for the specified application configured within the ZDX tenant. + + Args: + app_id (str): The unique ID for the ZDX application. + + Keyword Args: + since (int): The number of hours to look back for devices. + location_id (str): The unique ID for the location. + department_id (str): The unique ID for the department. + geo_id (str): The unique ID for the geolocation. + metric_name (str): The name of the metric to return. Available values are: + * `pft` - Page Fetch Time + * `dns` - DNS Time + * `availability` + + Returns: + :obj:`Box`: The application's ZDX metrics. + + Examples: + Return the ZDX metrics for the application with the ID of 999999999: + >>> zia.apps.get_app_metrics(app_id='999999999') + + Return the ZDX metrics for the app with an ID of 999999999 for the last 24 hours, including dns matrics, + geolocation, department and location IDs: + >>> zia.apps.get_app_metrics(app_id='999999999', since=24, metric_name='dns', location_id='888888888', + ... geo_id='777777777', department_id='666666666') + + """ + return self._get(f"apps/{app_id}/metrics", params=kwargs) + + @zdx_params + def list_app_users(self, app_id: str, **kwargs): + """ + Returns a list of users and devices that were used to access the specified application configured within + the ZDX tenant. + + Args: + app_id (str): The unique ID for the ZDX application. + + Keyword Args: + since (int): The number of hours to look back for devices. + location_id (str): The unique ID for the location. + department_id (str): The unique ID for the department. + geo_id (str): The unique ID for the geolocation. + score_bucket (str): The ZDX score bucket to filter by. Available values are: + * `poor` - 0-33 + * `okay` - 34-65 + * `good` - 66-100 + + Returns: + :obj:`BoxList`: The list of users and devices used to access the application. + + Examples: + Return a list of users and devices who have accessed the application with the ID of 999999999: + >>> for user in zia.apps.get_app_users(app_id='999999999'): + ... print(user) + + """ + return BoxList( + ZDXIterator( + self._api, + f"apps/{app_id}/users", + pagination="offset_limit", + **kwargs, + ) + ) + + @zdx_params + def get_app_user(self, app_id: str, user_id: str, **kwargs): + """ + Returns information on the specified user and device that was used to access the specified application + configured within the ZDX tenant. + + Args: + app_id (str): The unique ID for the ZDX application. + user_id (str): The unique ID for the ZDX user. + + Keyword Args: + since (int): The number of hours to look back for devices. + + Returns: + :obj:`Box`: The user and device information. + + Examples: + Return information on the user with the ID of 999999999 who has accessed the application with the ID of + 888888888: + >>> zia.apps.get_app_user(app_id='888888888', user_id='999999999') """ - return self._get(f"apps/{app_id}") + return self._get(f"apps/{app_id}/users/{user_id}", params=kwargs) From 54d16ff73f6f25e9ee93e421d4f25c0b5a980171 Mon Sep 17 00:00:00 2001 From: Mitch Kelly Date: Fri, 2 Jun 2023 14:52:40 +1000 Subject: [PATCH 27/54] chore: fix TODO Signed-off-by: Mitch Kelly --- pyzscaler/zdx/admin.py | 1 - 1 file changed, 1 deletion(-) diff --git a/pyzscaler/zdx/admin.py b/pyzscaler/zdx/admin.py index f39a09f..7bf323b 100644 --- a/pyzscaler/zdx/admin.py +++ b/pyzscaler/zdx/admin.py @@ -28,7 +28,6 @@ def list_departments(self, **kwargs) -> BoxList: @zdx_params def list_locations(self, **kwargs) -> BoxList: - # TODO: Check if the keyword arg is 'search' or 'q'. Docs are potentially wrong or inconsistent """ Returns a list of locations that are configured within ZDX. From 91558568a2b435925ef2fb621723a7d4bddf3aa9 Mon Sep 17 00:00:00 2001 From: Mitch Kelly Date: Fri, 2 Jun 2023 14:53:20 +1000 Subject: [PATCH 28/54] docs: Update docstrings for ZDX session endpoints Signed-off-by: Mitch Kelly --- pyzscaler/zdx/session.py | 22 +++++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/pyzscaler/zdx/session.py b/pyzscaler/zdx/session.py index 71b732c..dc2ae0f 100644 --- a/pyzscaler/zdx/session.py +++ b/pyzscaler/zdx/session.py @@ -31,10 +31,30 @@ def create_token(self, client_id: str, client_secret: str) -> Box: payload = {"key_id": client_id, "key_secret": api_secret_hash, "timestamp": epoch_time} - return self._post("oauth/token", json=payload).token + return self._post("oauth/token", json=payload) def validate_token(self): + """ + Validates the current ZDX JWT token. + + Returns: + :obj:`Box`: The validated session information. + + Examples: + >>> validation = zdx.session.validate() + + """ return self._get("oauth/validate") def get_jwks(self): + """ + Returns a JSON Web Key Set (JWKS) that contains the public keys that can be used to verify the JWT tokens. + + Returns: + :obj:`Box`: The JSON Web Key Set (JWKS). + + Examples: + >>> jwks = zdx.session.get_jwks() + + """ return self._get("oauth/jwks") From f29cd403f00d62cf2e13296b02ad21f6e99d3caa Mon Sep 17 00:00:00 2001 From: Mitch Kelly Date: Fri, 2 Jun 2023 15:04:20 +1000 Subject: [PATCH 29/54] feat: add ZDX Iterator feat: update zdx_params decorator Signed-off-by: Mitch Kelly --- pyzscaler/utils.py | 66 ++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 61 insertions(+), 5 deletions(-) diff --git a/pyzscaler/utils.py b/pyzscaler/utils.py index 9ed07dd..eb7ef2f 100644 --- a/pyzscaler/utils.py +++ b/pyzscaler/utils.py @@ -133,6 +133,51 @@ def _get_page(self) -> None: time.sleep(1) +class ZDXIterator(APIIterator): + """ + Iterator class for ZDX endpoints. + + """ + + def __init__(self, api, endpoint, limit=None, **kwargs): + super().__init__(api, **kwargs) + self.endpoint = endpoint + self.limit = limit + self.next_offset = None + self.total = 0 + + # Load the first page + self._get_page() + + def __next__(self): + try: + item = super().__next__() + except StopIteration: + if self.next_offset is None: + # There is no next page, so we're done iterating + raise + # There is another page, so get it and continue iterating + self._get_page() + item = super().__next__() + return item + + def _get_page(self): + params = {"limit": self.limit, "offset": self.next_offset} if self.next_offset else {} + + # Request the next page + response = self._api.get(self.endpoint, params=params) + + # Extract the next offset and the data items from the response + self.next_offset = response.get("next_offset") + self.page = response["users"] + + # Update the total number of records + self.total += len(self.page) + + # Reset page_count for the new page + self.page_count = 0 + + # Maps ZCC numeric os_type and registration_type arguments to a human-readable string zcc_param_map = { "os": { @@ -171,12 +216,23 @@ def zdx_params(func): """ - def wrapper(self, **kwargs): - if kwargs.get("since"): - current_time, past_time = calculate_epoch(kwargs.get("since")) + def wrapper(self, *args, **kwargs): + since = kwargs.pop("since", None) + search = kwargs.pop("search", None) + location_id = kwargs.pop("location_id", None) + department_id = kwargs.pop("department_id", None) + geo_id = kwargs.pop("geo_id", None) + + if since: + current_time, past_time = calculate_epoch(since) kwargs["to"] = current_time kwargs["from"] = past_time - del kwargs["since"] - return func(self, **kwargs) + + kwargs["q"] = search or kwargs.get("q") + kwargs["loc"] = location_id or kwargs.get("loc") + kwargs["dept"] = department_id or kwargs.get("dept") + kwargs["geo"] = geo_id or kwargs.get("geo") + + return func(self, *args, **kwargs) return wrapper From a81718dd78950aa6d51448cbb97a1e1e0f6476ef Mon Sep 17 00:00:00 2001 From: Mitch Kelly Date: Fri, 2 Jun 2023 15:05:27 +1000 Subject: [PATCH 30/54] feat: add support ZDX users endpoint Signed-off-by: Mitch Kelly --- pyzscaler/zdx/users.py | 52 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 52 insertions(+) create mode 100644 pyzscaler/zdx/users.py diff --git a/pyzscaler/zdx/users.py b/pyzscaler/zdx/users.py new file mode 100644 index 0000000..d732ce7 --- /dev/null +++ b/pyzscaler/zdx/users.py @@ -0,0 +1,52 @@ +from box import BoxList +from restfly.endpoint import APIEndpoint + +from pyzscaler.utils import ZDXIterator, zdx_params + + +class UsersAPI(APIEndpoint): + @zdx_params + def list_users(self, **kwargs) -> BoxList: + """ + Returns a list of all active users configured within the ZDX tenant. + + Keyword Args: + since (int): The number of hours to look back for devices. + location_id (str): The unique ID for the location. + department_id (str): The unique ID for the department. + geo_id (str): The unique ID for the geolocation. + + Returns: + :obj:`BoxList`: The list of users in ZDX. + + Examples: + List all users in ZDX for the past 2 hours + >>> for user in zdx.users.list_users(): + ... print(user) + + """ + return BoxList(ZDXIterator(self._api, "users", **kwargs)) + + @zdx_params + def get_user(self, user_id: str, **kwargs): + """ + Returns information on the specified user configured within the ZDX tenant. + + Args: + user_id (str): The unique ID for the ZDX user. + + Keyword Args: + since (int): The number of hours to look back for devices. + location_id (str): The unique ID for the location. + department_id (str): The unique ID for the department. + geo_id (str): The unique ID for the geolocation. + + Returns: + :obj:`Box`: The user information. + + Examples: + Return information on the user with the ID of 999999999: + >>> zia.users.get_user(user_id='999999999') + + """ + return self._get(f"users/{user_id}") From 139b578c7e54eb63b1604c15c74664bb5c97ea2b Mon Sep 17 00:00:00 2001 From: Mitch Kelly Date: Fri, 2 Jun 2023 15:09:40 +1000 Subject: [PATCH 31/54] feat: add support for ZDX users endpoint refactor: update ZDX build session to accomodate changes in ZDX session module Signed-off-by: Mitch Kelly --- pyzscaler/zdx/__init__.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/pyzscaler/zdx/__init__.py b/pyzscaler/zdx/__init__.py index 8295fd3..25710db 100644 --- a/pyzscaler/zdx/__init__.py +++ b/pyzscaler/zdx/__init__.py @@ -4,11 +4,11 @@ from restfly.session import APISession from pyzscaler import __version__ - from .admin import AdminAPI from .apps import AppsAPI from .devices import DevicesAPI from .session import SessionAPI +from .users import UsersAPI class ZDX(APISession): @@ -55,7 +55,7 @@ def __init__(self, **kw): def _build_session(self, **kwargs) -> Box: """Creates a ZCC API session.""" super(ZDX, self)._build_session(**kwargs) - self._auth_token = self.session.create_token(client_id=self._client_id, client_secret=self._client_secret) + self._auth_token = self.session.create_token(client_id=self._client_id, client_secret=self._client_secret).token return self._session.headers.update({"Authorization": f"Bearer {self._auth_token}"}) @property @@ -77,3 +77,8 @@ def apps(self): def devices(self): """The interface object for the :ref:`ZDX Devices interface `.""" return DevicesAPI(self) + + @property + def users(self): + """The interface object for the :ref:`ZDX Users interface `.""" + return UsersAPI(self) From 9744b6d4453afdac643e64253d63de4bd16e6d76 Mon Sep 17 00:00:00 2001 From: Mitch Kelly Date: Fri, 2 Jun 2023 15:13:37 +1000 Subject: [PATCH 32/54] refactor: drop support for Python 3.7 tests, add support for Python 3.11 Signed-off-by: Mitch Kelly --- .github/workflows/build.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 9d7f042..3baae83 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -26,7 +26,7 @@ jobs: strategy: fail-fast: false matrix: - python-version: [ 3.7, 3.8, 3.9, '3.10' ] + python-version: [ "3.8", "3.9", "3.10", "3.11" ] steps: - uses: actions/checkout@v2 From 8b30abb0b1674c0d388e548723633925b5298965 Mon Sep 17 00:00:00 2001 From: Mitch Kelly Date: Fri, 2 Jun 2023 19:50:19 +1000 Subject: [PATCH 33/54] fix: fixes #200, update docstrings for add and update location to include all possible kwargs Signed-off-by: Mitch Kelly --- pyzscaler/zia/locations.py | 184 ++++++++++++++++++++++++++++++++++--- 1 file changed, 169 insertions(+), 15 deletions(-) diff --git a/pyzscaler/zia/locations.py b/pyzscaler/zia/locations.py index ddc5a58..af39214 100644 --- a/pyzscaler/zia/locations.py +++ b/pyzscaler/zia/locations.py @@ -56,16 +56,91 @@ def add_location(self, name: str, **kwargs) -> Box: Location name. Keyword Args: - ip_addresses (list): + parent_id (int, optional): + Parent Location ID. If this ID does not exist or is 0, it is implied that it is a parent location. + up_bandwidth (int, optional): + Upload bandwidth in kbps. The value 0 implies no Bandwidth Control enforcement. Default: 0. + dn_bandwidth (int, optional): + Download bandwidth in kbps. The value 0 implies no Bandwidth Control enforcement. Default: 0. + country (str, optional): + Country. + tz (str, optional): + Timezone of the location. If not specified, it defaults to GMT. + ip_addresses (list[str], optional): For locations: IP addresses of the egress points that are provisioned in the Zscaler Cloud. Each entry is a single IP address (e.g., 238.10.33.9). For sub-locations: Egress, internal, or GRE tunnel IP addresses. Each entry is either a single IP address, CIDR (e.g., 10.10.33.0/24), or range (e.g., 10.10.33.1-10.10.33.10)). - ports (:obj:`list` of :obj:`str`): - List of whitelisted Proxy ports for the location. - vpn_credentials (dict): - VPN credentials for the location. + ports (list[int], optional): + IP ports that are associated with the location. + vpn_credentials (dict, optional): + VPN User Credentials that are associated with the location. + auth_required (bool, optional): + Enforce Authentication. Required when ports are enabled, IP Surrogate is enabled, or Kerberos + Authentication is enabled. Default: False. + ssl_scan_enabled (bool, optional): + Enable SSL Inspection. Set to true in order to apply your SSL Inspection policy to HTTPS traffic in + the location and inspect HTTPS transactions for data leakage, malicious content, and viruses. + Default: False. + zapp_ssl_scan_enabled (bool, optional): + Enable Zscaler App SSL Setting. When set to true, the Zscaler App SSL Scan Setting takes effect, + irrespective of the SSL policy that is configured for the location. Default: False. + xff_forward_enabled (bool, optional): + Enable XFF Forwarding for a location. When set to true, traffic is passed to Zscaler Cloud via the + X-Forwarded-For (XFF) header. Default: False. + other_sub_location (bool, optional): + If set to true, indicates that this is a default sub-location created by the Zscaler service to + accommodate IPv4 addresses that are not part of any user-defined sub-locations. Default: False. + other6_sub_location (bool, optional): + If set to true, indicates that this is a default sub-location created by the Zscaler service to + accommodate IPv6 addresses that are not part of any user-defined sub-locations. Default: False. + surrogate_ip (bool, optional): + Enable Surrogate IP. When set to true, users are mapped to internal device IP addresses. Default: False. + idle_time_in_minutes (int, optional): + Idle Time to Disassociation. The user mapping idle time (in minutes) is required if Surrogate IP is + enabled. + display_time_unit (str, optional): + Display Time Unit. The time unit to display for IP Surrogate idle time to disassociation. + surrogate_ip_enforced_for_known_browsers (bool, optional): + Enforce Surrogate IP for Known Browsers. When set to true, IP Surrogate is enforced for all known + browsers. Default: False. + surrogate_refresh_time_in_minutes (int, optional): + Refresh Time for re-validation of Surrogacy. The surrogate refresh time (in minutes) to re-validate + the IP surrogates. + surrogate_refresh_time_unit (str, optional): + Display Refresh Time Unit. The time unit to display for refresh time for re-validation of surrogacy. + ofw_enabled (bool, optional): + Enable Firewall. When set to true, Firewall is enabled for the location. Default: False. + ips_control (bool, optional): + Enable IPS Control. When set to true, IPS Control is enabled for the location if Firewall is enabled. + Default: False. + aup_enabled (bool, optional): + Enable AUP. When set to true, AUP is enabled for the location. Default: False. + caution_enabled (bool, optional): + Enable Caution. When set to true, a caution notification is enabled for the location. Default: False. + aup_block_internet_until_accepted (bool, optional): + For First Time AUP Behavior, Block Internet Access. When set, all internet access (including non-HTTP + traffic) is disabled until the user accepts the AUP. Default: False. + aup_force_ssl_inspection (bool, optional): + For First Time AUP Behavior, Force SSL Inspection. When set, Zscaler forces SSL Inspection in order + to enforce AUP for HTTPS traffic. Default: False. + ipv6_enabled (bool, optional): + If set to true, IPv6 is enabled for the location and IPv6 traffic from the location can be forwarded + to the Zscaler service to enforce security policies. + ipv6_dns64_prefix (str, optional): + Name-ID pair of the NAT64 prefix configured as the DNS64 prefix for the location. + aup_timeout_in_days (int, optional): + Custom AUP Frequency. Refresh time (in days) to re-validate the AUP. + managed_by (str, optional): + SD-WAN Partner that manages the location. If a partner does not manage the location, this is set to + Self. + profile (str, optional): + Profile tag that specifies the location traffic type. If not specified, this tag defaults to + "Unassigned". + description (str, optional): + Additional notes or information regarding the location or sub-location. The description cannot + exceed 1024 characters. Returns: :obj:`Box`: The newly created location resource record @@ -203,15 +278,94 @@ def update_location(self, location_id: str, **kwargs) -> Box: location_id (str): The unique identifier for the location you are updating. **kwargs: - Optional keyword args. + Optional keyword arguments. Keyword Args: - ip_addresses (:obj:`list` of :obj:`str`): - List of updated ip addresses. - ports (:obj:`list` of :obj:`str`): - List of whitelisted Proxy ports for the location. - vpn_credentials (dict): - VPN credentials for the location. + name (str, optional): + Location name. + parent_id (int, optional): + Parent Location ID. If this ID does not exist or is 0, it is implied that it is a parent location. + up_bandwidth (int, optional): + Upload bandwidth in kbps. The value 0 implies no Bandwidth Control enforcement. + dn_bandwidth (int, optional): + Download bandwidth in kbps. The value 0 implies no Bandwidth Control enforcement. + country (str, optional): + Country. + tz (str, optional): + Timezone of the location. If not specified, it defaults to GMT. + ip_addresses (list[str], optional): + For locations: IP addresses of the egress points that are provisioned in the Zscaler Cloud. + Each entry is a single IP address (e.g., 238.10.33.9). + + For sub-locations: Egress, internal, or GRE tunnel IP addresses. Each entry is either a single + IP address, CIDR (e.g., 10.10.33.0/24), or range (e.g., 10.10.33.1-10.10.33.10)). + ports (list[int], optional): + IP ports that are associated with the location. + vpn_credentials (dict, optional): + VPN User Credentials that are associated with the location. + auth_required (bool, optional): + Enforce Authentication. Required when ports are enabled, IP Surrogate is enabled, or Kerberos + Authentication is enabled. + ssl_scan_enabled (bool, optional): + Enable SSL Inspection. Set to true in order to apply your SSL Inspection policy to HTTPS traffic in the + location and inspect HTTPS transactions for data leakage, malicious content, and viruses. + zapp_ssl_scan_enabled (bool, optional): + Enable Zscaler App SSL Setting. When set to true, the Zscaler App SSL Scan Setting takes effect, + irrespective of the SSL policy that is configured for the location. + xff_forward_enabled (bool, optional): + Enable XFF Forwarding for a location. When set to true, traffic is passed to Zscaler Cloud via the + X-Forwarded-For (XFF) header. + other_sub_location (bool, optional): + If set to true, indicates that this is a default sub-location created by the Zscaler service to + accommodate IPv4 addresses that are not part of any user-defined sub-locations. + other6_sub_location (bool, optional): + If set to true, indicates that this is a default sub-location created by the Zscaler service to + accommodate IPv6 addresses that are not part of any user-defined sub-locations. + surrogate_ip (bool, optional): + Enable Surrogate IP. When set to true, users are mapped to internal device IP addresses. + idle_time_in_minutes (int, optional): + Idle Time to Disassociation. The user mapping idle time (in minutes) is required if a Surrogate IP is + enabled. + display_time_unit (str, optional): + Display Time Unit. The time unit to display for IP Surrogate idle time to disassociation. + surrogate_ip_enforced_for_known_browsers (bool, optional): + Enforce Surrogate IP for Known Browsers. When set to true, IP Surrogate is enforced for all known + browsers. + surrogate_refresh_time_in_minutes (int, optional): + Refresh Time for re-validation of Surrogacy. The surrogate refresh time (in minutes) to re-validate + the IP surrogates. + surrogate_refresh_time_unit (str, optional): + Display Refresh Time Unit. The time unit to display for refresh time for re-validation of surrogacy. + ofw_enabled (bool, optional): + Enable Firewall. When set to true, Firewall is enabled for the location. + ips_control (bool, optional): + Enable IPS Control. When set to true, IPS Control is enabled for the location if Firewall is enabled. + aup_enabled (bool, optional): + Enable AUP. When set to true, AUP is enabled for the location. + caution_enabled (bool, optional): + Enable Caution. When set to true, a caution notification is enabled for the location. + aup_block_internet_until_accepted (bool, optional): + For First Time AUP Behavior, Block Internet Access. When set, all internet access (including non-HTTP + traffic) is disabled until the user accepts the AUP. + aup_force_ssl_inspection (bool, optional): + For First Time AUP Behavior, Force SSL Inspection. When set, Zscaler forces SSL Inspection in order to + enforce AUP for HTTPS traffic. + ipv6_enabled (bool, optional): + If set to true, IPv6 is enabled for the location and IPv6 traffic from the location can be forwarded + to the Zscaler service to enforce security policies. + ipv6_dns64_prefix (str, optional): + Name-ID pair of the NAT64 prefix configured as the DNS64 prefix for the location. + aup_timeout_in_days (int, optional): + Custom AUP Frequency. Refresh time (in days) to re-validate the AUP. + managed_by (str, optional): + SD-WAN Partner that manages the location. If a partner does not manage the location, this is set to + Self. + profile (str, optional): + Profile tag that specifies the location traffic type. If not specified, this tag defaults to + "Unassigned". + description (str, optional): + Additional notes or information regarding the location or sub-location. The description cannot exceed + 1024 characters. Returns: :obj:`Box`: The updated resource record. @@ -219,12 +373,12 @@ def update_location(self, location_id: str, **kwargs) -> Box: Examples: Update the name of a location: - >>> zia.locations.update('97456691', + >>> zia.locations.update_location('97456691', ... name='updated_location_name') - Upodate the IP address of a location: + Update the IP address of a location: - >>> zia.locations.update('97456691', + >>> zia.locations.update_location('97456691', ... ip_addresses=['203.0.113.20']) """ From bcf5d84732bc6edbdcd569bdee9ce92a6938f9ce Mon Sep 17 00:00:00 2001 From: Mitch Kelly Date: Fri, 2 Jun 2023 19:57:55 +1000 Subject: [PATCH 34/54] fix: refactors start_deeptrace to be compatible with python3.8 Signed-off-by: Mitch Kelly --- pyzscaler/zdx/devices.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/pyzscaler/zdx/devices.py b/pyzscaler/zdx/devices.py index 6da9979..5e4e840 100644 --- a/pyzscaler/zdx/devices.py +++ b/pyzscaler/zdx/devices.py @@ -332,7 +332,8 @@ def start_deeptrace(self, device_id: str, app_id: str, session_name: str, **kwar payload = { "session_name": session_name, "app_id": app_id, - } | kwargs + } + payload.update(kwargs) return self._post(f"devices/{device_id}/deeptraces", json=payload) From f4c47299b0f5423df0f8fc48a599f746eff48cb1 Mon Sep 17 00:00:00 2001 From: Mitch Kelly Date: Fri, 2 Jun 2023 21:29:59 +1000 Subject: [PATCH 35/54] fix: fixes #198 where displayTimeUnit is a mandatory field for updating a ZIA Sublocation Signed-off-by: Mitch Kelly --- pyzscaler/zia/locations.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/pyzscaler/zia/locations.py b/pyzscaler/zia/locations.py index af39214..6fb0d2d 100644 --- a/pyzscaler/zia/locations.py +++ b/pyzscaler/zia/locations.py @@ -389,6 +389,10 @@ def update_location(self, location_id: str, **kwargs) -> Box: for key, value in kwargs.items(): payload[snake_to_camel(key)] = value + # Fixes edge case where the sublocation object is missing displayTimeUnit, which will result in a 500 error. + if not payload.get("displayTimeUnit"): + payload["displayTimeUnit"] = "MINUTE" + return self._put(f"locations/{location_id}", json=payload) def delete_location(self, location_id: str) -> int: From 7b68c582741a8801fe8e6259d7c5625fa969977b Mon Sep 17 00:00:00 2001 From: Mitch Kelly Date: Fri, 2 Jun 2023 21:32:03 +1000 Subject: [PATCH 36/54] test: update ZIA update_location test to include edge-case fix Signed-off-by: Mitch Kelly --- tests/zia/test_locations.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/zia/test_locations.py b/tests/zia/test_locations.py index be2bfc2..a3415bb 100644 --- a/tests/zia/test_locations.py +++ b/tests/zia/test_locations.py @@ -248,6 +248,7 @@ def test_add_location(zia, locations): def test_update_location(zia, locations): updated_location = locations[0] updated_location["name"] = "Updated Test" + updated_location["displayTimeUnit"] = "MINUTE" responses.add( responses.GET, From abef82a71b1cf3fc8048421fc0570f17d758d285 Mon Sep 17 00:00:00 2001 From: mkelly Date: Thu, 22 Jun 2023 06:42:22 +1000 Subject: [PATCH 37/54] docs: resolves #200, updates add and update location methods to clarify params Signed-off-by: mkelly --- pyzscaler/zia/locations.py | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/pyzscaler/zia/locations.py b/pyzscaler/zia/locations.py index 6fb0d2d..c14eef3 100644 --- a/pyzscaler/zia/locations.py +++ b/pyzscaler/zia/locations.py @@ -74,7 +74,7 @@ def add_location(self, name: str, **kwargs) -> Box: IP address, CIDR (e.g., 10.10.33.0/24), or range (e.g., 10.10.33.1-10.10.33.10)). ports (list[int], optional): IP ports that are associated with the location. - vpn_credentials (dict, optional): + vpn_credentials (list, optional): VPN User Credentials that are associated with the location. auth_required (bool, optional): Enforce Authentication. Required when ports are enabled, IP Surrogate is enabled, or Kerberos @@ -151,6 +151,11 @@ def add_location(self, name: str, **kwargs) -> Box: >>> zia.locations.add_location(name='new_location', ... ip_addresses=['203.0.113.10']) + Add a location with VPN credentials. + + >>> zia.locations.add_location(name='new_location', + ... vpn_credentials=[{'id': '99999', 'type': 'UFQDN'}]) + """ payload = { "name": name, @@ -301,7 +306,7 @@ def update_location(self, location_id: str, **kwargs) -> Box: IP address, CIDR (e.g., 10.10.33.0/24), or range (e.g., 10.10.33.1-10.10.33.10)). ports (list[int], optional): IP ports that are associated with the location. - vpn_credentials (dict, optional): + vpn_credentials (list, optional): VPN User Credentials that are associated with the location. auth_required (bool, optional): Enforce Authentication. Required when ports are enabled, IP Surrogate is enabled, or Kerberos @@ -373,14 +378,19 @@ def update_location(self, location_id: str, **kwargs) -> Box: Examples: Update the name of a location: - >>> zia.locations.update_location('97456691', + >>> zia.locations.update_location('99999', ... name='updated_location_name') Update the IP address of a location: - >>> zia.locations.update_location('97456691', + >>> zia.locations.update_location('99999', ... ip_addresses=['203.0.113.20']) + Update the VPN credentials of a location: + + >>> zia.locations.update_location('99999', + ... vpn_credentials=[{'id': '88888', 'type': 'UFQDN'}]) + """ # Set payload to value of existing record payload = {snake_to_camel(k): v for k, v in self.get_location(location_id).items()} From 34951aaceafb2a2b14be399908d33fe4a32d8980 Mon Sep 17 00:00:00 2001 From: mkelly Date: Thu, 22 Jun 2023 06:53:42 +1000 Subject: [PATCH 38/54] chore: update user dependencies chore: update dev dependencies, closes #203, closes #202, closes #201, closes #195, closes #192, closes #183, closes #178 Signed-off-by: mkelly --- .pre-commit-config.yaml | 4 ++-- dev_requirements.txt | 14 +++++++------- pyproject.toml | 16 ++++++++-------- pyzscaler/zdx/__init__.py | 1 + requirements.txt | 2 +- 5 files changed, 19 insertions(+), 18 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 3b8912e..b591d56 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,13 +1,13 @@ exclude: ^(docsrc/|docs/|examples/) repos: - repo: https://github.com/psf/black - rev: 22.8.0 + rev: 23.3.0 hooks: - id: black language_version: python3.11 - repo: https://github.com/PyCQA/flake8 - rev: 4.0.1 + rev: 6.0.0 hooks: - id: flake8 diff --git a/dev_requirements.txt b/dev_requirements.txt index e140242..8d91dec 100644 --- a/dev_requirements.txt +++ b/dev_requirements.txt @@ -1,9 +1,9 @@ -furo==2022.12.7 -pre-commit==3.0.4 -pytest==7.2.1 -python-box==7.0.0 +furo==2023.5.20 +pre-commit==3.3.3 +pytest==7.3.2 +python-box==7.0.1 restfly==1.4.7 -requests==2.28.2 -responses==0.22.0 -sphinx==6.1.3 +requests==2.31.0 +responses==0.23.1 +sphinx==7.0.1 toml==0.10.2 \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index d6c7174..45cef5e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -32,18 +32,18 @@ include = [ [tool.poetry.dependencies] python = "^3.7" restfly = "1.4.7" -python-box = "7.0.0" +python-box = "7.0.1" [tool.poetry.dev-dependencies] python = "^3.7" restfly = "1.4.7" -python-box = "7.0.0" -sphinx = "6.1.3" -furo = "2022.12.7" -pytest = "7.2.1" -requests = "2.28.2" -pre-commit = "3.0.4" -responses = "0.22.0" +python-box = "7.0.1" +sphinx = "7.0.1" +furo = "2023.5.20" +pytest = "7.3.2" +requests = "2.31.0" +pre-commit = "3.3.3" +responses = "0.23.1" toml = "0.10.2" [build-system] diff --git a/pyzscaler/zdx/__init__.py b/pyzscaler/zdx/__init__.py index 25710db..cd0d752 100644 --- a/pyzscaler/zdx/__init__.py +++ b/pyzscaler/zdx/__init__.py @@ -4,6 +4,7 @@ from restfly.session import APISession from pyzscaler import __version__ + from .admin import AdminAPI from .apps import AppsAPI from .devices import DevicesAPI diff --git a/requirements.txt b/requirements.txt index 5415bb4..4cb07a9 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,2 +1,2 @@ restfly==1.4.7 -python-box==7.0.0 \ No newline at end of file +python-box==7.0.1 \ No newline at end of file From bb8f29c3f1750769960aef70d8a84481e6a4a541 Mon Sep 17 00:00:00 2001 From: mkelly Date: Thu, 22 Jun 2023 11:31:43 +1000 Subject: [PATCH 39/54] refactor: update ZDX Params decorator to include functools wrapper, fixing docstrings Signed-off-by: mkelly --- pyzscaler/utils.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/pyzscaler/utils.py b/pyzscaler/utils.py index eb7ef2f..d5ff411 100644 --- a/pyzscaler/utils.py +++ b/pyzscaler/utils.py @@ -1,3 +1,4 @@ +import functools import time from box import Box, BoxList @@ -216,6 +217,7 @@ def zdx_params(func): """ + @functools.wraps(func) def wrapper(self, *args, **kwargs): since = kwargs.pop("since", None) search = kwargs.pop("search", None) From 0b603ce7a76d24f56518ce8a49dd9ab682275677 Mon Sep 17 00:00:00 2001 From: mkelly Date: Thu, 22 Jun 2023 11:34:38 +1000 Subject: [PATCH 40/54] refactor: update ZDX init to use absolute imports for submodules Signed-off-by: mkelly --- pyzscaler/zdx/__init__.py | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/pyzscaler/zdx/__init__.py b/pyzscaler/zdx/__init__.py index cd0d752..bd0edf7 100644 --- a/pyzscaler/zdx/__init__.py +++ b/pyzscaler/zdx/__init__.py @@ -4,12 +4,11 @@ from restfly.session import APISession from pyzscaler import __version__ - -from .admin import AdminAPI -from .apps import AppsAPI -from .devices import DevicesAPI -from .session import SessionAPI -from .users import UsersAPI +from pyzscaler.zdx.admin import AdminAPI +from pyzscaler.zdx.apps import AppsAPI +from pyzscaler.zdx.devices import DevicesAPI +from pyzscaler.zdx.session import SessionAPI +from pyzscaler.zdx.users import UsersAPI class ZDX(APISession): From 99b0e97a0fd020300958bb50672b9a5cee9976e0 Mon Sep 17 00:00:00 2001 From: mkelly Date: Thu, 22 Jun 2023 11:36:03 +1000 Subject: [PATCH 41/54] fix: fixes deprecated intersphinx mapping option Signed-off-by: mkelly --- docsrc/conf.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docsrc/conf.py b/docsrc/conf.py index 990de03..465f772 100644 --- a/docsrc/conf.py +++ b/docsrc/conf.py @@ -95,7 +95,7 @@ # Add any paths that contain custom static files (such as style sheets) here, # relative to this directory. They are copied after the builtin static files, # so a file named "default.css" will overwrite the builtin "default.css". -html_static_path = ["_static"] +html_static_path = [] # Custom sidebar templates, must be a dictionary that maps document names # to template names. @@ -182,7 +182,7 @@ # -- Options for intersphinx extension --------------------------------------- # Example configuration for intersphinx: refer to the Python standard library. -intersphinx_mapping = {"https://docs.python.org/": None} +intersphinx_mapping = {'python': ('https://docs.python.org/3', None)} # -- Options for todo extension ---------------------------------------------- From c8d1967dbdae98a92bc8252c6d222f7da65d2840 Mon Sep 17 00:00:00 2001 From: mkelly Date: Thu, 22 Jun 2023 11:36:23 +1000 Subject: [PATCH 42/54] docs: updates docstring to use correct example Signed-off-by: mkelly --- pyzscaler/zpa/connectors.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pyzscaler/zpa/connectors.py b/pyzscaler/zpa/connectors.py index 919f36e..034fbda 100644 --- a/pyzscaler/zpa/connectors.py +++ b/pyzscaler/zpa/connectors.py @@ -150,7 +150,7 @@ def list_connector_groups(self, **kwargs) -> BoxList: :obj:`BoxList`: List of all configured connector groups. Examples: - >>> connector_groups = zpa.connector_groups.list_groups() + >>> connector_groups = zpa.connectors.list_connector_groups() """ return BoxList(Iterator(self._api, "appConnectorGroup", **kwargs)) @@ -168,7 +168,7 @@ def get_connector_group(self, group_id: str) -> Box: The connector group resource record. Examples: - >>> connector_group = zpa.connector_groups.get_group('99999') + >>> connector_group = zpa.connectors.get_connector_group('99999') """ return self._get(f"appConnectorGroup/{group_id}") From 9b34c2a8a4bb3c18b24327c5f7e0f0401bf83358 Mon Sep 17 00:00:00 2001 From: mkelly Date: Thu, 22 Jun 2023 11:37:43 +1000 Subject: [PATCH 43/54] chore: drops support for EOL Python 3.7 Signed-off-by: mkelly --- pyproject.toml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 45cef5e..14ba6bc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -16,10 +16,10 @@ classifiers = [ "License :: OSI Approved :: MIT License", "Natural Language :: English", "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.7", "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", "Topic :: Security", "Topic :: Software Development :: Libraries :: Python Modules", ] include = [ @@ -30,12 +30,12 @@ include = [ "Bug Tracker" = "https://github.com/mitchos/pyZscaler/issues" [tool.poetry.dependencies] -python = "^3.7" +python = "^3.8" restfly = "1.4.7" python-box = "7.0.1" [tool.poetry.dev-dependencies] -python = "^3.7" +python = "^3.8" restfly = "1.4.7" python-box = "7.0.1" sphinx = "7.0.1" From 5854e3df83ac1bad86f78ac156850f1906f89e0e Mon Sep 17 00:00:00 2001 From: mkelly Date: Thu, 22 Jun 2023 11:38:20 +1000 Subject: [PATCH 44/54] docs: adds docs for zdx.users module Signed-off-by: mkelly --- docsrc/zs/zdx/users.rst | 12 ++++++++++++ 1 file changed, 12 insertions(+) create mode 100644 docsrc/zs/zdx/users.rst diff --git a/docsrc/zs/zdx/users.rst b/docsrc/zs/zdx/users.rst new file mode 100644 index 0000000..8448712 --- /dev/null +++ b/docsrc/zs/zdx/users.rst @@ -0,0 +1,12 @@ +users +------- + +The following methods allow for interaction with the ZDX +Users API endpoints. + +Methods are accessible via ``zdx.users`` + +.. _zdx-users: + +.. automodule:: pyzscaler.zdx.users + :members: \ No newline at end of file From e9633371674f9dd6e9fb92c7f76826ef33a1406e Mon Sep 17 00:00:00 2001 From: mkelly Date: Thu, 22 Jun 2023 12:46:31 +1000 Subject: [PATCH 45/54] chore: removes references to unused company_id parameter Signed-off-by: mkelly --- docsrc/zs/zcc/index.rst | 13 ------------- pyzscaler/zcc/__init__.py | 4 ---- 2 files changed, 17 deletions(-) diff --git a/docsrc/zs/zcc/index.rst b/docsrc/zs/zcc/index.rst index 487bf03..aad87db 100644 --- a/docsrc/zs/zcc/index.rst +++ b/docsrc/zs/zcc/index.rst @@ -2,19 +2,6 @@ ZCC ========== This package covers the ZCC interface. -Retrieving the ZCC Company ID. -^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - -The ZCC Company ID can be obtained by following these instructions: - 1. Navigate to the Zscaler Mobile Admin Portal in a web browser. - 2. Open the Browser console (typically ``F12``) and click on **Network**. - 3. From the top navigation, click on **Enrolled Devices**. - 4. Look for the API call ``mobileadmin.zscaler.net/webservice/api/web/usersByCompany`` in the 'Networks' tab - of the Browser Console. Click on this entry. - 5. Click on either **Preview** or **Response** to see the data that was returned by the Mobile Admin Portal. - 6. The Company ID is represented as an ``int`` and can be found under the ``companyId`` key in the object returned - for each user. - .. toctree:: :maxdepth: 1 :glob: diff --git a/pyzscaler/zcc/__init__.py b/pyzscaler/zcc/__init__.py index d8d3dcf..dee639b 100644 --- a/pyzscaler/zcc/__init__.py +++ b/pyzscaler/zcc/__init__.py @@ -27,9 +27,6 @@ class ZCC(APISession): * ``zscalerthree`` * ``zscloud`` * ``zscalerbeta`` - company_id (str): - The ZCC Company ID. There seems to be no easy way to obtain this at present. See the note - at the top of this page for information on how to retrieve the Company ID. override_url (str): If supplied, this attribute can be used to override the production URL that is derived from supplying the `cloud` attribute. Use this attribute if you have a non-standard tenant URL @@ -57,7 +54,6 @@ def __init__(self, **kw): kw.get("override_url", os.getenv(f"{self._env_base}_OVERRIDE_URL")) or f"https://api-mobile.{self._env_cloud}.net/papi" ) - self.company_id = kw.get("company_id", os.getenv(f"{self._env_base}_COMPANY_ID")) self.conv_box = True super(ZCC, self).__init__(**kw) From ac85c71895f365f19cdcea6d7561144a541d9ff3 Mon Sep 17 00:00:00 2001 From: mkelly Date: Thu, 22 Jun 2023 12:48:30 +1000 Subject: [PATCH 46/54] chore!: removes deprecated zpa connector_groups endpoints Signed-off-by: mkelly --- docsrc/zs/zpa/connector_groups.rst | 12 ------- pyzscaler/zpa/__init__.py | 9 ----- pyzscaler/zpa/connector_groups.py | 56 ------------------------------ 3 files changed, 77 deletions(-) delete mode 100644 docsrc/zs/zpa/connector_groups.rst delete mode 100644 pyzscaler/zpa/connector_groups.py diff --git a/docsrc/zs/zpa/connector_groups.rst b/docsrc/zs/zpa/connector_groups.rst deleted file mode 100644 index 6247278..0000000 --- a/docsrc/zs/zpa/connector_groups.rst +++ /dev/null @@ -1,12 +0,0 @@ -connector_groups ------------------ - -The following methods allow for interaction with the ZPA -Connector Groups API endpoints. - -Methods are accessible via ``zpa.connector_groups`` - -.. _zpa-connector_groups: - -.. automodule:: pyzscaler.zpa.connector_groups - :members: \ No newline at end of file diff --git a/pyzscaler/zpa/__init__.py b/pyzscaler/zpa/__init__.py index 8466e83..0c58c7b 100644 --- a/pyzscaler/zpa/__init__.py +++ b/pyzscaler/zpa/__init__.py @@ -6,7 +6,6 @@ from pyzscaler.zpa.app_segments import AppSegmentsAPI from pyzscaler.zpa.certificates import CertificatesAPI from pyzscaler.zpa.cloud_connector_groups import CloudConnectorGroupsAPI -from pyzscaler.zpa.connector_groups import ConnectorGroupsAPI from pyzscaler.zpa.connectors import ConnectorsAPI from pyzscaler.zpa.idp import IDPControllerAPI from pyzscaler.zpa.inspection import InspectionControllerAPI @@ -114,14 +113,6 @@ def cloud_connector_groups(self): """ return CloudConnectorGroupsAPI(self) - @property - def connector_groups(self): - """ - The interface object for the :ref:`ZPA Connector Groups interface `. - - """ - return ConnectorGroupsAPI(self) - @property def connectors(self): """ diff --git a/pyzscaler/zpa/connector_groups.py b/pyzscaler/zpa/connector_groups.py deleted file mode 100644 index 345240c..0000000 --- a/pyzscaler/zpa/connector_groups.py +++ /dev/null @@ -1,56 +0,0 @@ -from warnings import warn - -from box import Box, BoxList -from restfly.endpoint import APIEndpoint - -from pyzscaler.utils import Iterator - - -class ConnectorGroupsAPI(APIEndpoint): - def list_groups(self, **kwargs) -> BoxList: - """ - Returns a list of all connector groups. - - Warnings: - .. deprecated:: 0.13.0 - Use :func:`zpa.connectors.list_connector_groups` instead. - - Returns: - :obj:`BoxList`: List of all configured connector groups. - - Examples: - >>> connector_groups = zpa.connector_groups.list_groups() - - """ - warn( - "This endpoint is deprecated and will eventually be removed. " - "Use zpa.connectors.list_connector_groups() instead." - ) - - return BoxList(Iterator(self._api, "appConnectorGroup", **kwargs)) - - def get_group(self, group_id: str) -> Box: - """ - Get information for a specified connector group. - - Warnings: - .. deprecated:: 0.13.0 - Use :func:`zpa.connectors.get_connector_group` instead. - - Args: - group_id (str): - The unique identifier for the connector group. - - Returns: - :obj:`Box`: - The connector group resource record. - - Examples: - >>> connector_group = zpa.connector_groups.get_group('2342342354545455') - - """ - warn( - "This endpoint is deprecated and will eventually be removed. " "Use zpa.connectors.get_connector_group() instead." - ) - - return self._get(f"appConnectorGroup/{group_id}") From 9806b4a2da29a49bc6196b5696a0555080ed905c Mon Sep 17 00:00:00 2001 From: mkelly Date: Thu, 22 Jun 2023 12:49:23 +1000 Subject: [PATCH 47/54] style: renames Web DLP module to comply with pyzscaler naming conventions Signed-off-by: mkelly --- pyzscaler/zia/__init__.py | 6 +++--- pyzscaler/zia/web_dlp.py | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/pyzscaler/zia/__init__.py b/pyzscaler/zia/__init__.py index 9be087a..994245f 100644 --- a/pyzscaler/zia/__init__.py +++ b/pyzscaler/zia/__init__.py @@ -21,7 +21,7 @@ from .url_filters import URLFilteringAPI from .users import UserManagementAPI from .vips import DataCenterVIPSAPI -from .web_dlp import WebDLP +from .web_dlp import WebDLPAPI class ZIA(APISession): @@ -215,7 +215,7 @@ def vips(self): @property def web_dlp(self): """ - The interface object for the :ref: `ZIA Data-Loss-Prevention Web DLP Rules`. + The interface object for the :ref:`ZIA Web DLP interface `. """ - return WebDLP(self) + return WebDLPAPI(self) diff --git a/pyzscaler/zia/web_dlp.py b/pyzscaler/zia/web_dlp.py index c269bac..32c8395 100644 --- a/pyzscaler/zia/web_dlp.py +++ b/pyzscaler/zia/web_dlp.py @@ -4,7 +4,7 @@ from restfly.endpoint import APIEndpoint -class WebDLP(APIEndpoint): +class WebDLPAPI(APIEndpoint): def list_rules(self, **kwargs) -> BoxList: """ Returns a list of DLP policy rules, excluding SaaS Security API DLP policy rules. From e92ba859570a34b96b79b42414402bfe978a9b39 Mon Sep 17 00:00:00 2001 From: mkelly Date: Thu, 22 Jun 2023 12:49:55 +1000 Subject: [PATCH 48/54] docs: standardises zia index.rst auto-index with rest of pyzscaler docs Signed-off-by: mkelly --- docsrc/zs/zia/index.rst | 21 +++------------------ 1 file changed, 3 insertions(+), 18 deletions(-) diff --git a/docsrc/zs/zia/index.rst b/docsrc/zs/zia/index.rst index 0580de8..06f7003 100644 --- a/docsrc/zs/zia/index.rst +++ b/docsrc/zs/zia/index.rst @@ -3,25 +3,10 @@ ZIA This package covers the ZIA interface. .. toctree:: - :maxdepth: 2 + :glob: + :hidden: - admin_and_role_management - audit_logs - config - dlp - firewall - locations - rule_labels - sandbox - security - session - ssl_inspection - traffic - url_categories - url_filters - users - vips - web_dlp + * .. automodule:: pyzscaler.zia :members: \ No newline at end of file From 149e9b5aa1a9b6515ab01e952128132897414c67 Mon Sep 17 00:00:00 2001 From: mkelly Date: Thu, 22 Jun 2023 12:51:30 +1000 Subject: [PATCH 49/54] docs: fixes docstrings with incorrect syntax Signed-off-by: mkelly --- pyzscaler/zdx/admin.py | 9 ++++++--- pyzscaler/zdx/apps.py | 9 ++++++++- pyzscaler/zdx/devices.py | 6 ++++-- pyzscaler/zdx/users.py | 4 +++- 4 files changed, 21 insertions(+), 7 deletions(-) diff --git a/pyzscaler/zdx/admin.py b/pyzscaler/zdx/admin.py index 7bf323b..a58aab0 100644 --- a/pyzscaler/zdx/admin.py +++ b/pyzscaler/zdx/admin.py @@ -18,7 +18,8 @@ def list_departments(self, **kwargs) -> BoxList: :obj:`BoxList`: The list of departments in ZDX. Examples: - List all departments in ZDX for the past 2 hours + List all departments in ZDX for the past 2 hours: + >>> for department in zdx.admin.list_departments(): ... print(department) @@ -39,7 +40,8 @@ def list_locations(self, **kwargs) -> BoxList: :obj:`BoxList`: The list of locations in ZDX. Examples: - List all locations in ZDX for the past 2 hours + List all locations in ZDX for the past 2 hours: + >>> for location in zdx.admin.list_locations(): ... print(location) @@ -61,7 +63,8 @@ def list_geolocations(self, **kwargs) -> BoxList: :obj:`BoxList`: The list of geolocations in ZDX. Examples: - List all geolocations in ZDX for the past 2 hours + List all geolocations in ZDX for the past 2 hours: + >>> for geolocation in zdx.admin.list_geolocations(): ... print(geolocation) diff --git a/pyzscaler/zdx/apps.py b/pyzscaler/zdx/apps.py index 0ba152b..2c967f2 100644 --- a/pyzscaler/zdx/apps.py +++ b/pyzscaler/zdx/apps.py @@ -20,7 +20,8 @@ def list_apps(self, **kwargs) -> BoxList: :obj:`BoxList`: The list of applications in ZDX. Examples: - List all applications in ZDX for the past 2 hours + List all applications in ZDX for the past 2 hours: + >>> for app in zdx.apps.list_apps(): ... print(app) @@ -46,6 +47,7 @@ def get_app(self, app_id: str, **kwargs): Examples: Return information on the application with the ID of 999999999: + >>> zia.apps.get_app(app_id='999999999') """ @@ -70,6 +72,7 @@ def get_app_score(self, app_id: str, **kwargs): Examples: Return the ZDX score trend for the application with the ID of 999999999: + >>> zia.apps.get_app_score(app_id='999999999') """ @@ -98,10 +101,12 @@ def get_app_metrics(self, app_id: str, **kwargs): Examples: Return the ZDX metrics for the application with the ID of 999999999: + >>> zia.apps.get_app_metrics(app_id='999999999') Return the ZDX metrics for the app with an ID of 999999999 for the last 24 hours, including dns matrics, geolocation, department and location IDs: + >>> zia.apps.get_app_metrics(app_id='999999999', since=24, metric_name='dns', location_id='888888888', ... geo_id='777777777', department_id='666666666') @@ -132,6 +137,7 @@ def list_app_users(self, app_id: str, **kwargs): Examples: Return a list of users and devices who have accessed the application with the ID of 999999999: + >>> for user in zia.apps.get_app_users(app_id='999999999'): ... print(user) @@ -164,6 +170,7 @@ def get_app_user(self, app_id: str, user_id: str, **kwargs): Examples: Return information on the user with the ID of 999999999 who has accessed the application with the ID of 888888888: + >>> zia.apps.get_app_user(app_id='888888888', user_id='999999999') """ diff --git a/pyzscaler/zdx/devices.py b/pyzscaler/zdx/devices.py index 5e4e840..6f05d7d 100644 --- a/pyzscaler/zdx/devices.py +++ b/pyzscaler/zdx/devices.py @@ -19,10 +19,12 @@ def list_devices(self, **kwargs): :obj:`BoxList`: The list of devices in ZDX. Examples: - List all devices in ZDX for the past 2 hours + List all devices in ZDX for the past 2 hours: + >>> for device in zdx.devices.list_devices(): - List all devices in ZDX for the past 24 hours + List all devices in ZDX for the past 24 hours: + >>> for device in zdx.devices.list_devices(since=24): """ diff --git a/pyzscaler/zdx/users.py b/pyzscaler/zdx/users.py index d732ce7..149a6dc 100644 --- a/pyzscaler/zdx/users.py +++ b/pyzscaler/zdx/users.py @@ -20,7 +20,8 @@ def list_users(self, **kwargs) -> BoxList: :obj:`BoxList`: The list of users in ZDX. Examples: - List all users in ZDX for the past 2 hours + List all users in ZDX for the past 2 hours: + >>> for user in zdx.users.list_users(): ... print(user) @@ -46,6 +47,7 @@ def get_user(self, user_id: str, **kwargs): Examples: Return information on the user with the ID of 999999999: + >>> zia.users.get_user(user_id='999999999') """ From 39e947052c86808938250894773536b72abd85e3 Mon Sep 17 00:00:00 2001 From: mkelly Date: Thu, 22 Jun 2023 12:51:50 +1000 Subject: [PATCH 50/54] chore: removes obsolete ZPA Connector Group tests Signed-off-by: mkelly --- tests/zpa/test_zpa_connector_groups.py | 46 -------------------------- 1 file changed, 46 deletions(-) delete mode 100644 tests/zpa/test_zpa_connector_groups.py diff --git a/tests/zpa/test_zpa_connector_groups.py b/tests/zpa/test_zpa_connector_groups.py deleted file mode 100644 index b3a43f6..0000000 --- a/tests/zpa/test_zpa_connector_groups.py +++ /dev/null @@ -1,46 +0,0 @@ -import pytest -import responses -from box import Box, BoxList - -from tests.conftest import stub_sleep - - -# Don't need to test the data structure as we just have list and get -# methods available. id will suffice until add/update endpoints are available. -@pytest.fixture(name="connector_groups") -def fixture_connector_groups(): - return {"totalPages": 1, "list": [{"id": "1"}, {"id": "2"}]} - - -@responses.activate -@stub_sleep -def test_depr_list_connector_groups(zpa, connector_groups): - responses.add( - responses.GET, - url="https://config.private.zscaler.com/mgmtconfig/v1/admin/customers/1/appConnectorGroup?page=1", - json=connector_groups, - status=200, - ) - responses.add( - responses.GET, - url="https://config.private.zscaler.com/mgmtconfig/v1/admin/customers/1/appConnectorGroup?page=2", - json=[], - status=200, - ) - resp = zpa.connector_groups.list_groups() - assert isinstance(resp, BoxList) - assert len(resp) == 2 - assert resp[0].id == "1" - - -@responses.activate -def test_depr_get_connector_groups(zpa, connector_groups): - responses.add( - responses.GET, - url="https://config.private.zscaler.com/mgmtconfig/v1/admin/customers/1/appConnectorGroup/1", - json=connector_groups["list"][0], - status=200, - ) - resp = zpa.connector_groups.get_group("1") - assert isinstance(resp, Box) - assert resp.id == "1" From dcb61a76b87ad7358aa6be75f6ffffbac33d49e5 Mon Sep 17 00:00:00 2001 From: mkelly Date: Thu, 22 Jun 2023 12:52:25 +1000 Subject: [PATCH 51/54] docs: updates intersphinx mappings Signed-off-by: mkelly --- docsrc/conf.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/docsrc/conf.py b/docsrc/conf.py index 465f772..84ec5bf 100644 --- a/docsrc/conf.py +++ b/docsrc/conf.py @@ -182,7 +182,10 @@ # -- Options for intersphinx extension --------------------------------------- # Example configuration for intersphinx: refer to the Python standard library. -intersphinx_mapping = {'python': ('https://docs.python.org/3', None)} +intersphinx_mapping = {'python': ('https://docs.python.org/3', None), + 'restfly': ('https://restfly.readthedocs.io/en/latest/', None), + 'box': ('https://box.readthedocs.io/en/latest', None), + } # -- Options for todo extension ---------------------------------------------- From 9f75214bd87659db9b921e773fc6d55671a5731f Mon Sep 17 00:00:00 2001 From: mkelly Date: Thu, 22 Jun 2023 12:53:07 +1000 Subject: [PATCH 52/54] refactor: refactor pick_version_profile util to be more readable Signed-off-by: mkelly --- pyzscaler/utils.py | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/pyzscaler/utils.py b/pyzscaler/utils.py index d5ff411..46d924f 100644 --- a/pyzscaler/utils.py +++ b/pyzscaler/utils.py @@ -80,13 +80,7 @@ def obfuscate_api_key(seed: list): def pick_version_profile(kwargs: list, payload: list): - # Used in ZPA endpoints. - # This function is used to convert the name of the version profile to - # the version profile id. This means our users don't need to look up the - # version profile id mapping themselves. - - version_profile = kwargs.pop("version_profile", None) - if version_profile: + if version_profile := kwargs.pop("version_profile", None): payload["overrideVersionProfile"] = True if version_profile == "default": payload["versionProfileId"] = 0 From 195e2a4f0f8ec0a8575f82e68db83025a6b52c7a Mon Sep 17 00:00:00 2001 From: mkelly Date: Thu, 22 Jun 2023 13:09:49 +1000 Subject: [PATCH 53/54] fix: pins urllib3 to earlier version to resolve incompatible urllib3 2.0.3 and restfly library Signed-off-by: mkelly --- dev_requirements.txt | 3 ++- pyproject.toml | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/dev_requirements.txt b/dev_requirements.txt index 8d91dec..def83e9 100644 --- a/dev_requirements.txt +++ b/dev_requirements.txt @@ -6,4 +6,5 @@ restfly==1.4.7 requests==2.31.0 responses==0.23.1 sphinx==7.0.1 -toml==0.10.2 \ No newline at end of file +toml==0.10.2 +urllib3<2 \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index 14ba6bc..ea960c6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -41,10 +41,11 @@ python-box = "7.0.1" sphinx = "7.0.1" furo = "2023.5.20" pytest = "7.3.2" -requests = "2.31.0" +requests = "2.29.0" pre-commit = "3.3.3" responses = "0.23.1" toml = "0.10.2" +urllib3 = "1.26.16" [build-system] requires = ["poetry-core>=1.0.0"] From 6f7b5ab675f94a17dda3c8463e6ca0a215288648 Mon Sep 17 00:00:00 2001 From: mkelly Date: Thu, 22 Jun 2023 13:11:19 +1000 Subject: [PATCH 54/54] Bumps version to 1.5.0 Signed-off-by: mkelly --- docsrc/conf.py | 4 ++-- pyproject.toml | 2 +- pyzscaler/__init__.py | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/docsrc/conf.py b/docsrc/conf.py index 84ec5bf..2f62cf0 100644 --- a/docsrc/conf.py +++ b/docsrc/conf.py @@ -25,9 +25,9 @@ html_title = "" # The short X.Y version -version = '1.4' +version = '1.5' # The full version, including alpha/beta/rc tags -release = '1.4.1' +release = '1.5.0' # -- General configuration --------------------------------------------------- diff --git a/pyproject.toml b/pyproject.toml index ea960c6..9632398 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "pyzscaler" -version = "1.4.1" +version = "1.5.0" description = "A python SDK for the Zscaler API." authors = ["Mitch Kelly "] license = "MIT" diff --git a/pyzscaler/__init__.py b/pyzscaler/__init__.py index ddf3d6e..12f25c7 100644 --- a/pyzscaler/__init__.py +++ b/pyzscaler/__init__.py @@ -4,7 +4,7 @@ "Dax Mickelson", "Jacob Gårder", ] -__version__ = "1.4.1" +__version__ = "1.5.0" from pyzscaler.zcc import ZCC # noqa from pyzscaler.zdx import ZDX # noqa