diff --git a/README.md b/README.md index fcc30d5..d91ac1a 100644 --- a/README.md +++ b/README.md @@ -34,6 +34,7 @@ by Steve McGrath. ## Products - Zscaler Private Access (ZPA) - Zscaler Internet Access (ZIA) +- Zscaler Mobile Admin Portal for Zscaler Client Connector (ZCC) - Cloud Security Posture Management (CSPM) - (work in progress) @@ -72,6 +73,18 @@ for app_segment in zpa.app_segments.list_segments(): pprint(app_segment) ``` +### Quick ZCC Example + +```python +from pyzscaler import ZCC +from pprint import pprint + +zcc = ZCC(client_id='CLIENT_ID', client_secret='CLIENT_SECRET', company_id='COMPANY_ID') +for device in zcc.devices.list_devices(): + pprint(device) +``` + + ## Documentation ### API Documentation pyZscaler's API is fully 100% documented and is hosted at [ReadTheDocs](https://pyzscaler.readthedocs.io). diff --git a/docsrc/index.rst b/docsrc/index.rst index 07535da..9eb8bf7 100644 --- a/docsrc/index.rst +++ b/docsrc/index.rst @@ -8,6 +8,7 @@ zs/zia/index zs/zpa/index + zs/zcc/index pyZscaler SDK - Library Reference ===================================================================== @@ -39,6 +40,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) Installation @@ -83,6 +85,20 @@ Quick ZPA Example for app_segment in zpa.app_segments.list_segments(): pprint(app_segment) + +Quick ZCC Example +^^^^^^^^^^^^^^^^^^^ + +.. code-block:: python + + from pyzscaler import ZCC + from pprint import pprint + + zcc = ZCC(client_id='CLIENT_ID', client_secret='CLIENT_SECRET', company_id='COMPANY_ID) + for device in zcc.devices.list_devices(): + pprint(device) + + .. automodule:: pyzscaler :members: diff --git a/docsrc/zs/zcc/devices.rst b/docsrc/zs/zcc/devices.rst new file mode 100644 index 0000000..2ceb445 --- /dev/null +++ b/docsrc/zs/zcc/devices.rst @@ -0,0 +1,12 @@ +devices +-------------- + +The following methods allow for interaction with the ZCC +Devices API endpoints. + +Methods are accessible via ``zcc.devices`` + +.. _zcc-devices: + +.. automodule:: pyzscaler.zcc.devices + :members: \ No newline at end of file diff --git a/docsrc/zs/zcc/index.rst b/docsrc/zs/zcc/index.rst new file mode 100644 index 0000000..487bf03 --- /dev/null +++ b/docsrc/zs/zcc/index.rst @@ -0,0 +1,26 @@ +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: + :hidden: + + * + +.. automodule:: pyzscaler.zcc + :members: diff --git a/docsrc/zs/zcc/secrets.rst b/docsrc/zs/zcc/secrets.rst new file mode 100644 index 0000000..c0a26c1 --- /dev/null +++ b/docsrc/zs/zcc/secrets.rst @@ -0,0 +1,11 @@ +secrets +-------------- + +The following methods allow for interaction with the ZCC API endpoints for managing secrets. + +Methods are accessible via ``zcc.secrets`` + +.. _zcc-secrets: + +.. automodule:: pyzscaler.zcc.secrets + :members: \ No newline at end of file diff --git a/docsrc/zs/zcc/session.rst b/docsrc/zs/zcc/session.rst new file mode 100644 index 0000000..bcdb80e --- /dev/null +++ b/docsrc/zs/zcc/session.rst @@ -0,0 +1,12 @@ +session +-------------- + +The following methods allow for interaction with the ZCC +Session API endpoints. + +Methods are accessible via ``zcc.session`` + +.. _zcc-session: + +.. automodule:: pyzscaler.zcc.session + :members: \ No newline at end of file diff --git a/pyzscaler/__init__.py b/pyzscaler/__init__.py index 4a3d633..da8d0e2 100644 --- a/pyzscaler/__init__.py +++ b/pyzscaler/__init__.py @@ -6,5 +6,6 @@ ] __version__ = "1.1.1" +from pyzscaler.zcc import ZCC # noqa from pyzscaler.zia import ZIA # noqa from pyzscaler.zpa import ZPA # noqa diff --git a/pyzscaler/zcc/__init__.py b/pyzscaler/zcc/__init__.py new file mode 100644 index 0000000..123932a --- /dev/null +++ b/pyzscaler/zcc/__init__.py @@ -0,0 +1,64 @@ +import os + +from box import Box +from restfly.session import APISession + +from pyzscaler import __version__ + +from .devices import DevicesAPI +from .secrets import SecretsAPI +from .session import AuthenticatedSessionAPI + + +class ZCC(APISession): + """ + A Controller to access Endpoints in the Zscaler Mobile Admin Portal API. + + The ZCC object stores the session token and simplifies access to CRUD options within the ZCC 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. + 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. + + """ + + _vendor = "Zscaler" + _product = "Zscaler Mobile Admin Portal" + _backoff = 3 + _build = __version__ + _box = True + _box_attrs = {"camel_killer_box": True} + _env_base = "ZCC" + _env_cloud = "zscaler" + _url = "https://api-mobile.zscaler.net/papi" + + 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.company_id = kw.get("company_id", os.getenv(f"{self._env_base}_COMPANY_ID")) + self.conv_box = True + super(ZCC, self).__init__(**kw) + + def _build_session(self, **kwargs) -> Box: + """Creates a ZCC API session.""" + super(ZCC, 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({"auth-token": f"{self._auth_token}"}) + + @property + def devices(self): + """The interface object for the :ref:`ZCC Devices interface `.""" + return DevicesAPI(self) + + @property + def secrets(self): + """The interface object for the :ref:`ZCC Secrets interface `.""" + return SecretsAPI(self) + + @property + def session(self): + """The interface object for the :ref:`ZCC Authenticated Session interface `.""" + return AuthenticatedSessionAPI(self) diff --git a/pyzscaler/zcc/devices.py b/pyzscaler/zcc/devices.py new file mode 100644 index 0000000..961c190 --- /dev/null +++ b/pyzscaler/zcc/devices.py @@ -0,0 +1,27 @@ +from box import BoxList +from restfly import APISession +from restfly.endpoint import APIEndpoint + + +class DevicesAPI(APIEndpoint): + def __init__(self, api: APISession): + super().__init__(api) + self.company_id = api.company_id + + def list_devices(self) -> BoxList: + """ + Returns the list of devices enrolled in the Mobile Admin Portal. + + Returns: + :obj:`BoxList`: A list containing devices using ZCC enrolled in the Mobile Admin Portal. + + Examples: + Prints all devices in the Mobile Admin Portal to the console: + + >>> for device in zcc.devices.list_devices(): + ... print(device) + + """ + payload = {"companyId": self.company_id} + + return self._get("public/v1/getDevices", json=payload) diff --git a/pyzscaler/zcc/secrets.py b/pyzscaler/zcc/secrets.py new file mode 100644 index 0000000..2fc63ec --- /dev/null +++ b/pyzscaler/zcc/secrets.py @@ -0,0 +1,79 @@ +from restfly import APISession +from restfly.endpoint import APIEndpoint + + +class SecretsAPI(APIEndpoint): + os_map = { + "ios": 1, + "android": 2, + "windows": 3, + "macos": 4, + "linux": 5, + } + + def __init__(self, api: APISession): + super().__init__(api) + self.company_id = api.company_id + + def get_otp(self, device_id: str): + """ + Returns the OTP code for the specified device id. + + Args: + device_id (str): The unique id for the enrolled device that the OTP will be obtained for. + + Returns: + :obj:`Box`: A dictionary containing the requested OTP code for the specified device id. + + Examples: + Obtain the OTP code for a device and print it to console: + + >>> otp_code = zcc.secrets.get_otp('System-Serial-Number:1234ABCDEF') + ... print(otp_code.otp) + + """ + + payload = {"udid": device_id} + + return self._get("public/v1/getOtp", params=payload) + + def get_passwords(self, username: str, os_type: str = "windows"): + """ + Return passwords for the specified username and device OS type. + + Args: + username (str): The username that the device belongs to. + os_type (str): The OS Type for the device, defaults to `windows`. Valid options are: + + - ios + - android + - windows + - macos + - linux + + Returns: + :obj:`Box`: Dictionary containing passwords for the specified username's device. + + Examples: + Print macos device passwords for username test@example.com: + + >>> print(zcc.secrets.get_passwords(username='test@example.com', + ... os_type='macos')) + + """ + + payload = { + "companyId": self.company_id, + } + + # Simplify the os_type argument, raise an error if the user supplies the wrong one. + os_type = self.os_map.get(os_type, None) + if not os_type: + raise ValueError("Invalid os_type specified. Check the pyZscaler documentation for valid os_type options.") + + params = { + "username": username, + "osType": os_type, + } + + return self._get("public/v1/getPasswords", data=payload, params=params) diff --git a/pyzscaler/zcc/session.py b/pyzscaler/zcc/session.py new file mode 100644 index 0000000..6352072 --- /dev/null +++ b/pyzscaler/zcc/session.py @@ -0,0 +1,30 @@ +from box import Box +from restfly.endpoint import APIEndpoint + + +class AuthenticatedSessionAPI(APIEndpoint): + def create_token(self, client_id: str, client_secret: str) -> Box: + """ + Creates a ZCC authentication token. + + Args: + client_id (str): The ZCC Portal Client ID. + client_secret (str): The ZCC Portal Client Secret. + + Returns: + :obj:`Box`: The authenticated session information. + + Examples: + >>> zia.session.create(api_key='999999999', + ... username='admin@example.com', + ... password='MyInsecurePassword') + + + """ + + payload = { + "apiKey": client_id, + "secretKey": client_secret, + } + + return self._post("auth/v1/login", json=payload).jwt_token diff --git a/tests/zcc/conftest.py b/tests/zcc/conftest.py new file mode 100644 index 0000000..798201d --- /dev/null +++ b/tests/zcc/conftest.py @@ -0,0 +1,28 @@ +import pytest +import responses + +from pyzscaler.zcc import ZCC + + +@pytest.fixture(name="session") +def fixture_session(): + return { + "jwtToken": "ADMIN_LOGIN", + } + + +@pytest.fixture(name="zcc") +@responses.activate +def zcc(session): + responses.add( + responses.POST, + url="https://api-mobile.zscaler.net/papi/auth/v1/login", + content_type="application/json", + json=session, + status=200, + ) + return ZCC( + client_id="abc123", + client_secret="999999", + company_id="88888", + ) diff --git a/tests/zcc/test_zcc_devices.py b/tests/zcc/test_zcc_devices.py new file mode 100644 index 0000000..b58f54e --- /dev/null +++ b/tests/zcc/test_zcc_devices.py @@ -0,0 +1,24 @@ +import pytest +import responses +from box import BoxList +from responses import matchers + + +@pytest.fixture(name="devices") +def fixture_devices(): + return [{"id": 1}, {"id": 2}] + + +@responses.activate +def test_list_devices(devices, zcc): + responses.add( + method="GET", + url="https://api-mobile.zscaler.net/papi/public/v1/getDevices", + json=devices, + match=[matchers.json_params_matcher({"companyId": "88888"})], + status=200, + ) + resp = zcc.devices.list_devices() + + assert isinstance(resp, BoxList) + assert resp[0].id == 1 diff --git a/tests/zcc/test_zcc_secrets.py b/tests/zcc/test_zcc_secrets.py new file mode 100644 index 0000000..655e3ad --- /dev/null +++ b/tests/zcc/test_zcc_secrets.py @@ -0,0 +1,69 @@ +import pytest +import responses +from box import Box +from responses import matchers + + +@responses.activate +def test_get_otp(zcc): + responses.add( + method="GET", + url="https://api-mobile.zscaler.net/papi/public/v1/getOtp", + json={"otp": "123abc"}, + match=[matchers.query_param_matcher({"udid": "999999"})], + status=200, + ) + resp = zcc.secrets.get_otp("999999") + + assert isinstance(resp, Box) + assert resp.otp == "123abc" + + +@responses.activate +def test_get_passwords_default(zcc): + responses.add( + method="GET", + url="https://api-mobile.zscaler.net/papi/public/v1/getPasswords", + json={ + "logout_pass": "test", + "exit_pass": "test", + "zia_disable_pass": "test", + "zpa_disable_pass": "test", + "zdx_disable_pass": "test", + "uninstall_pass": "test", + }, + match=[matchers.query_param_matcher({"username": "test@example.com", "osType": 3})], + status=200, + ) + resp = zcc.secrets.get_passwords("test@example.com") + + assert isinstance(resp, Box) + assert resp.logout_pass == "test" + + +@responses.activate +def test_get_passwords_os_type(zcc): + responses.add( + method="GET", + url="https://api-mobile.zscaler.net/papi/public/v1/getPasswords", + json={ + "logout_pass": "test", + "exit_pass": "test", + "zia_disable_pass": "test", + "zpa_disable_pass": "test", + "zdx_disable_pass": "test", + "uninstall_pass": "test", + }, + match=[matchers.query_param_matcher({"username": "test@example.com", "osType": 1})], + status=200, + ) + resp = zcc.secrets.get_passwords("test@example.com", os_type="ios") + + assert isinstance(resp, Box) + assert resp.logout_pass == "test" + + +@responses.activate +def test_get_passwords_error(zcc): + with pytest.raises(Exception) as e_info: + resp = zcc.secrets.get_passwords("test@example.com", os_type="unix") diff --git a/tests/zcc/test_zcc_session.py b/tests/zcc/test_zcc_session.py new file mode 100644 index 0000000..e55e5d1 --- /dev/null +++ b/tests/zcc/test_zcc_session.py @@ -0,0 +1,17 @@ +import responses + + +@responses.activate +def test_create_token(zcc, session): + + responses.add( + responses.POST, + url="https://api-mobile.zscaler.net/papi/auth/v1/login", + json=session, + status=200, + ) + + resp = zcc.session.create_token(client_id="abc123", client_secret="999999") + + assert isinstance(resp, str) + assert resp == "ADMIN_LOGIN"