From 526e8713aa963cf5695323ce6cef508d773fedd3 Mon Sep 17 00:00:00 2001 From: Calvin Remsburg Date: Wed, 9 Oct 2024 20:49:37 -0500 Subject: [PATCH 01/11] Move IDE-specific ignores to main .gitignore Deleted the outdated .idea/.gitignore file and migrated its contents to the main .gitignore file. This consolidates all ignore rules and improves project configuration clarity. --- .gitignore | 9 +++++++++ .idea/.gitignore | 8 -------- 2 files changed, 9 insertions(+), 8 deletions(-) delete mode 100644 .idea/.gitignore diff --git a/.gitignore b/.gitignore index 01f62a5c..d73f146a 100644 --- a/.gitignore +++ b/.gitignore @@ -242,3 +242,12 @@ secrets.yaml # Exclude poetry lock poetry.lock /.idea/vcs.xml + +# Default ignored files +/shelf/ +/workspace.xml +# Editor-based HTTP Client requests +/httpRequests/ +# Datasource local storage ignored files +/dataSources/ +/dataSources.local.xml diff --git a/.idea/.gitignore b/.idea/.gitignore deleted file mode 100644 index 13566b81..00000000 --- a/.idea/.gitignore +++ /dev/null @@ -1,8 +0,0 @@ -# Default ignored files -/shelf/ -/workspace.xml -# Editor-based HTTP Client requests -/httpRequests/ -# Datasource local storage ignored files -/dataSources/ -/dataSources.local.xml From 6190587980c139cd53d516dd4cde27e9512402af Mon Sep 17 00:00:00 2001 From: Calvin Remsburg Date: Wed, 9 Oct 2024 20:50:04 -0500 Subject: [PATCH 02/11] Remove .idea/modules.xml file This commit deletes the .idea/modules.xml file. This change helps to clean up project-specific configuration files not necessary for version control. --- .idea/modules.xml | 8 -------- 1 file changed, 8 deletions(-) delete mode 100644 .idea/modules.xml diff --git a/.idea/modules.xml b/.idea/modules.xml deleted file mode 100644 index bd48fc4c..00000000 --- a/.idea/modules.xml +++ /dev/null @@ -1,8 +0,0 @@ - - - - - - - - \ No newline at end of file From 931d59cb2eff5bc0c85ac5b90f02d26ed225288b Mon Sep 17 00:00:00 2001 From: Calvin Remsburg Date: Wed, 9 Oct 2024 20:50:37 -0500 Subject: [PATCH 03/11] Add IntelliJ project file to .gitignore The file pan-scm-sdk.iml is specific to IntelliJ IDEA and should not be tracked in the repository. This change helps prevent unnecessary file clutter and potential merge conflicts. --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index d73f146a..c51dde4d 100644 --- a/.gitignore +++ b/.gitignore @@ -251,3 +251,4 @@ poetry.lock # Datasource local storage ignored files /dataSources/ /dataSources.local.xml +/.idea/pan-scm-sdk.iml From ac37bc531cf25d9e7790495f296e7bef1ba4e05a Mon Sep 17 00:00:00 2001 From: Calvin Remsburg Date: Wed, 9 Oct 2024 20:51:03 -0500 Subject: [PATCH 04/11] Add new endpoints and exceptions to the SDK Introduced address, address group, and application endpoints to the `API_ENDPOINTS` dictionary. Defined new custom exception classes: `NotFoundError`, and `ConflictError`. All __init__.py files are also updated to ensure they end with a newline. --- pan_scm_sdk/__init__.py | 2 +- pan_scm_sdk/auth/__init__.py | 2 +- pan_scm_sdk/endpoints/__init__.py | 14 +++++++++++--- pan_scm_sdk/exceptions/__init__.py | 20 +++++++++++++++++++- pan_scm_sdk/models/__init__.py | 2 +- pan_scm_sdk/resources/__init__.py | 0 pan_scm_sdk/utils/__init__.py | 2 +- 7 files changed, 34 insertions(+), 8 deletions(-) create mode 100644 pan_scm_sdk/resources/__init__.py diff --git a/pan_scm_sdk/__init__.py b/pan_scm_sdk/__init__.py index 8db46663..a06f1ef8 100644 --- a/pan_scm_sdk/__init__.py +++ b/pan_scm_sdk/__init__.py @@ -1 +1 @@ -# pan_scm_sdk/__init__.py \ No newline at end of file +# pan_scm_sdk/__init__.py diff --git a/pan_scm_sdk/auth/__init__.py b/pan_scm_sdk/auth/__init__.py index 386a230e..150e7859 100644 --- a/pan_scm_sdk/auth/__init__.py +++ b/pan_scm_sdk/auth/__init__.py @@ -1 +1 @@ -# pan_scm_sdk/auth/__init__.py \ No newline at end of file +# pan_scm_sdk/auth/__init__.py diff --git a/pan_scm_sdk/endpoints/__init__.py b/pan_scm_sdk/endpoints/__init__.py index 3122e216..8c4e4356 100644 --- a/pan_scm_sdk/endpoints/__init__.py +++ b/pan_scm_sdk/endpoints/__init__.py @@ -1,7 +1,15 @@ # pan_scm_sdk/endpoints/__init__.py +from .addresses import ADDRESSES_ENDPOINTS +from .address_groups import ADDRESS_GROUPS_ENDPOINTS +from .applications import APPLICATIONS_ENDPOINTS + +# more on the way + + API_ENDPOINTS = { - 'get_example': '/example/endpoint', - 'post_example': '/example/endpoint', - # Add other endpoints as needed + **ADDRESSES_ENDPOINTS, + **ADDRESS_GROUPS_ENDPOINTS, + **APPLICATIONS_ENDPOINTS, + # more on the way } diff --git a/pan_scm_sdk/exceptions/__init__.py b/pan_scm_sdk/exceptions/__init__.py index 95f3ef65..a9dc5286 100644 --- a/pan_scm_sdk/exceptions/__init__.py +++ b/pan_scm_sdk/exceptions/__init__.py @@ -1,3 +1,21 @@ # pan_scm_sdk/exceptions/__init__.py -from .authentication import APIError, AuthenticationError, ValidationError + +class APIError(Exception): + """Base class for API exceptions.""" + + +class AuthenticationError(APIError): + """Raised when authentication fails.""" + + +class ValidationError(APIError): + """Raised when data validation fails.""" + + +class NotFoundError(APIError): + """Raised when a requested resource is not found.""" + + +class ConflictError(APIError): + """Raised when there is a conflict in the request.""" diff --git a/pan_scm_sdk/models/__init__.py b/pan_scm_sdk/models/__init__.py index 7b71770f..6545f5df 100644 --- a/pan_scm_sdk/models/__init__.py +++ b/pan_scm_sdk/models/__init__.py @@ -1 +1 @@ -# pan_scm_sdk/models/__init__.py \ No newline at end of file +# pan_scm_sdk/models/__init__.py diff --git a/pan_scm_sdk/resources/__init__.py b/pan_scm_sdk/resources/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/pan_scm_sdk/utils/__init__.py b/pan_scm_sdk/utils/__init__.py index 924b656f..fb14fa33 100644 --- a/pan_scm_sdk/utils/__init__.py +++ b/pan_scm_sdk/utils/__init__.py @@ -1 +1 @@ -# pan_scm_sdk/utils/__init__.py \ No newline at end of file +# pan_scm_sdk/utils/__init__.py From 4ba465156eae55d3ba7b591dcb6e1009428e6fa1 Mon Sep 17 00:00:00 2001 From: Calvin Remsburg Date: Wed, 9 Oct 2024 20:51:11 -0500 Subject: [PATCH 05/11] Add unit tests for AddressClient and Address model Introduce new test cases for `AddressClient` to validate `list_addresses` and `get_address` methods using mock responses. Extend coverage for the `Address` model by testing valid and invalid scenarios related to address types and containers. --- tests/test_address_client.py | 62 +++++++++++++++++++++++++++++ tests/test_address_objects.py | 74 +++++++++++++++++++++++++++++++++++ 2 files changed, 136 insertions(+) create mode 100644 tests/test_address_client.py create mode 100644 tests/test_address_objects.py diff --git a/tests/test_address_client.py b/tests/test_address_client.py new file mode 100644 index 00000000..d3485e6a --- /dev/null +++ b/tests/test_address_client.py @@ -0,0 +1,62 @@ +# tests/test_address_client.py + +from unittest.mock import MagicMock +from pan_scm_sdk.client import APIClient +from pan_scm_sdk.resources.address import AddressClient +from pan_scm_sdk.models.address import Address + + +def test_list_addresses(): + api_client = APIClient( + client_id="dummy_client_id", + client_secret="dummy_client_secret", + tsg_id="dummy_tsg_id", + ) + api_client.get = MagicMock( + return_value={ + "data": [ + { + "id": "1", + "name": "Address1", + "ip_netmask": "192.168.1.0/24", + "folder": "Folder1", + }, + { + "id": "2", + "name": "Address2", + "fqdn": "example.com", + "folder": "Folder1", + }, + ] + } + ) + + address_client = AddressClient(api_client) + addresses = address_client.list_addresses() + assert len(addresses) == 2 + assert addresses[0].name == "Address1" + assert addresses[1].fqdn == "example.com" + + +def test_get_address(): + api_client = APIClient( + client_id="dummy_client_id", + client_secret="dummy_client_secret", + tsg_id="dummy_tsg_id", + ) + api_client.get = MagicMock( + return_value={ + "id": "1", + "name": "Address1", + "ip_netmask": "192.168.1.0/24", + "folder": "Folder1", + } + ) + + address_client = AddressClient(api_client) + address = address_client.get_address("1") + assert address.id == "1" + assert address.ip_netmask == "192.168.1.0/24" + + +# Additional tests for create, update, delete diff --git a/tests/test_address_objects.py b/tests/test_address_objects.py new file mode 100644 index 00000000..977407b5 --- /dev/null +++ b/tests/test_address_objects.py @@ -0,0 +1,74 @@ +# tests/test_address_model.py + +import pytest +from pydantic import ValidationError +from pan_scm_sdk.models.address import Address + + +def test_address_valid_ip_netmask(): + address_data = { + "id": "123e4567-e89b-12d3-a456-426655440000", + "name": "Test Address", + "ip_netmask": "192.168.1.1/24", + "folder": "My Folder", + } + address = Address(**address_data) + assert address.ip_netmask == "192.168.1.1/24" + assert address.folder == "My Folder" + + +def test_address_invalid_multiple_address_types(): + address_data = { + "id": "123e4567-e89b-12d3-a456-426655440001", + "name": "Invalid Address", + "ip_netmask": "192.168.1.1/24", + "fqdn": "example.com", + "folder": "My Folder", + } + with pytest.raises(ValidationError) as exc_info: + Address(**address_data) + assert ( + "Exactly one of 'ip_netmask', 'ip_range', 'ip_wildcard', or 'fqdn' must be provided." + in str(exc_info.value) + ) + + +def test_address_missing_address_type(): + address_data = { + "id": "123e4567-e89b-12d3-a456-426655440002", + "name": "No Address Type", + "folder": "My Folder", + } + with pytest.raises(ValidationError) as exc_info: + Address(**address_data) + assert ( + "Exactly one of 'ip_netmask', 'ip_range', 'ip_wildcard', or 'fqdn' must be provided." + in str(exc_info.value) + ) + + +def test_address_valid_container(): + address_data = { + "id": "123e4567-e89b-12d3-a456-426655440003", + "name": "Valid Container", + "ip_range": "10.0.0.1-10.0.0.4", + "device": "Device1", + } + address = Address(**address_data) + assert address.ip_range == "10.0.0.1-10.0.0.4" + assert address.device == "Device1" + + +def test_address_invalid_multiple_containers(): + address_data = { + "id": "123e4567-e89b-12d3-a456-426655440004", + "name": "Invalid Container", + "ip_wildcard": "10.20.1.0/0.0.248.255", + "folder": "Folder1", + "snippet": "Snippet1", + } + with pytest.raises(ValidationError) as exc_info: + Address(**address_data) + assert "Exactly one of 'folder', 'snippet', or 'device' must be provided." in str( + exc_info.value + ) From fb0684cb3648f2bed47a375a79439a228b120012 Mon Sep 17 00:00:00 2001 From: Calvin Remsburg Date: Wed, 9 Oct 2024 20:51:48 -0500 Subject: [PATCH 06/11] Delete unused authentication exceptions and add Address management Removed the entire `authentication.py` file due to unused exceptions. Enhanced the `AuthRequest` class with better documentation and added a new `AddressClient` class for address management. Additionally, improved error handling in `APIClient` and added logging. --- pan_scm_sdk/client.py | 44 ++++++- pan_scm_sdk/exceptions/authentication.py | 10 -- pan_scm_sdk/models/address.py | 141 +++++++++++++++++++++++ pan_scm_sdk/models/auth.py | 29 ++++- pan_scm_sdk/models/errors.py | 0 pan_scm_sdk/resources/address.py | 75 ++++++++++++ pan_scm_sdk/utils/logging.py | 18 ++- 7 files changed, 295 insertions(+), 22 deletions(-) delete mode 100644 pan_scm_sdk/exceptions/authentication.py create mode 100644 pan_scm_sdk/models/address.py create mode 100644 pan_scm_sdk/models/errors.py create mode 100644 pan_scm_sdk/resources/address.py diff --git a/pan_scm_sdk/client.py b/pan_scm_sdk/client.py index f8d01559..76abd3ac 100644 --- a/pan_scm_sdk/client.py +++ b/pan_scm_sdk/client.py @@ -1,4 +1,5 @@ # pan_scm_sdk/client.py +import requests from pan_scm_sdk.auth.oauth2 import OAuth2Client from pan_scm_sdk.models.auth import AuthRequest @@ -7,7 +8,26 @@ logger = setup_logger(__name__) + class APIClient: + """ + A client for interacting with the Palo Alto Networks Strata Cloud Manager API. + + This class provides methods for authenticating and making HTTP requests to the Strata API, + including GET, POST, PUT, and DELETE operations. It handles token refresh automatically. + + Attributes: + api_base_url (str): The base URL for the Strata API. + oauth_client (OAuth2Client): An instance of the OAuth2Client for authentication. + session (requests.Session): A session object for making HTTP requests. + + Error: + APIError: Raised when API initialization or requests fail. + + Return: + dict: JSON response from the API for successful requests. + """ + def __init__( self, client_id: str, @@ -20,9 +40,7 @@ def __init__( # Create the AuthRequest object try: auth_request = AuthRequest( - client_id=client_id, - client_secret=client_secret, - tsg_id=tsg_id + client_id=client_id, client_secret=client_secret, tsg_id=tsg_id ) except ValueError as e: logger.error(f"Authentication initialization failed: {e}") @@ -38,6 +56,12 @@ def request(self, method: str, endpoint: str, **kwargs): response = self.session.request(method, url, **kwargs) response.raise_for_status() return response.json() + except requests.exceptions.HTTPError as http_err: + error_content = response.json() if response.content else {} + logger.error(f"HTTP error occurred: {http_err} - {error_content}") + raise APIError( + f"HTTP error occurred: {http_err} - {error_content}" + ) from http_err except Exception as e: logger.error(f"API request failed: {str(e)}") raise APIError(f"API request failed: {str(e)}") from e @@ -45,11 +69,19 @@ def request(self, method: str, endpoint: str, **kwargs): def get(self, endpoint: str, **kwargs): if self.oauth_client.is_expired: self.oauth_client.refresh_token() - return self.request('GET', endpoint, **kwargs) + return self.request("GET", endpoint, **kwargs) def post(self, endpoint: str, **kwargs): if self.oauth_client.is_expired: self.oauth_client.refresh_token() - return self.request('POST', endpoint, **kwargs) + return self.request("POST", endpoint, **kwargs) - # Implement other methods as needed + def put(self, endpoint: str, **kwargs): + if self.oauth_client.is_expired: + self.oauth_client.refresh_token() + return self.request("PUT", endpoint, **kwargs) + + def delete(self, endpoint: str, **kwargs): + if self.oauth_client.is_expired: + self.oauth_client.refresh_token() + return self.request("DELETE", endpoint, **kwargs) diff --git a/pan_scm_sdk/exceptions/authentication.py b/pan_scm_sdk/exceptions/authentication.py deleted file mode 100644 index 4a2fbf9a..00000000 --- a/pan_scm_sdk/exceptions/authentication.py +++ /dev/null @@ -1,10 +0,0 @@ -# pan_scm_sdk/exceptions/authentication.py - -class APIError(Exception): - """Base class for API exceptions.""" - -class AuthenticationError(APIError): - """Raised when authentication fails.""" - -class ValidationError(APIError): - """Raised when data validation fails.""" diff --git a/pan_scm_sdk/models/address.py b/pan_scm_sdk/models/address.py new file mode 100644 index 00000000..8aaea11c --- /dev/null +++ b/pan_scm_sdk/models/address.py @@ -0,0 +1,141 @@ +from typing import Optional, List + +from pydantic import BaseModel, Field, model_validator, ConfigDict + + +class Address(BaseModel): + """ + Represents an Address object for Palo Alto Networks' Strata Cloud Manager. + + This class defines the structure and validation rules for an Address object, + including required fields, optional fields, address types, and container types. + + Attributes: + id (str): The UUID of the address object. + name (str): The name of the address object. + description (Optional[str]): The description of the address object. + tag (Optional[List[str]]): Tags associated with the address object. + ip_netmask (str): IP address with or without CIDR notation. + ip_range (str): IP address range. + ip_wildcard (str): IP wildcard mask. + fqdn (str): Fully qualified domain name. + folder (Optional[str]): The folder in which the resource is defined. + snippet (Optional[str]): The snippet in which the resource is defined. + device (Optional[str]): The device in which the resource is defined. + + Error: + ValueError: Raised when address type or container type validation fails. + + """ + + # Model configuration + model_config = ConfigDict( + validate_assignment=True, + arbitrary_types_allowed=True, + ) + + # Required fields + id: str = Field( + ..., + description="The UUID of the address object", + examples=["123e4567-e89b-12d3-a456-426655440000"], + ) + name: str = Field( + ..., + max_length=63, + description="The name of the address object", + ) + + # Optional fields + description: Optional[str] = Field( + None, + max_length=1023, + description="The description of the address object", + ) + tag: Optional[List[str]] = Field( + None, + max_length=64, + description="Tags associated with the address object", + ) + + # Address Types + ip_netmask: Optional[str] = Field( + None, + description="IP address with or without CIDR notation", + examples=["192.168.80.0/24"], + ) + ip_range: Optional[str] = Field( + None, + description="IP address range", + examples=["10.0.0.1-10.0.0.4"], + ) + ip_wildcard: Optional[str] = Field( + None, + description="IP wildcard mask", + examples=["10.20.1.0/0.0.248.255"], + ) + fqdn: Optional[str] = Field( + None, + description="Fully qualified domain name", + examples=["some.example.com"], + min_length=1, + max_length=255, + pattern=r"^[a-zA-Z0-9_]([a-zA-Z0-9._-])*[a-zA-Z0-9]$", + ) + + # Container Types + folder: str = Field( + ..., + pattern=r"^[a-zA-Z\d\-_. ]+$", + max_length=64, + description="The folder in which the resource is defined", + examples=["Prisma Access"], + ) + snippet: Optional[str] = Field( + None, + pattern=r"^[a-zA-Z\d\-_. ]+$", + max_length=64, + description="The snippet in which the resource is defined", + examples=["My Snippet"], + ) + device: Optional[str] = Field( + None, + pattern=r"^[a-zA-Z\d\-_. ]+$", + max_length=64, + description="The device in which the resource is defined", + examples=["My Device"], + ) + + # Custom Validators + @model_validator(mode="after") + def validate_address_type(self) -> "Address": + address_fields = [ + "ip_netmask", + "ip_range", + "ip_wildcard", + "fqdn", + ] + provided = [ + field for field in address_fields if getattr(self, field) is not None + ] + if len(provided) != 1: + raise ValueError( + "Exactly one of 'ip_netmask', 'ip_range', 'ip_wildcard', or 'fqdn' must be provided." + ) + return self + + @model_validator(mode="after") + def validate_container_type(self) -> "Address": + container_fields = [ + "folder", + "snippet", + "device", + ] + provided = [ + field for field in container_fields if getattr(self, field) is not None + ] + if len(provided) != 1: + raise ValueError( + "Exactly one of 'folder', 'snippet', or 'device' must be provided." + ) + return self diff --git a/pan_scm_sdk/models/auth.py b/pan_scm_sdk/models/auth.py index 0ae245ad..27979f95 100644 --- a/pan_scm_sdk/models/auth.py +++ b/pan_scm_sdk/models/auth.py @@ -2,7 +2,26 @@ from pydantic import BaseModel, Field, model_validator + class AuthRequest(BaseModel): + """ + Represents an authentication request for Palo Alto Network's Strata Cloud Manager. + + This class defines the structure and validation for authentication requests, + including client credentials, TSG ID, and scope construction. + + Attributes: + client_id (str): The client ID for authentication. + client_secret (str): The client secret for authentication. + tsg_id (str): The TSG ID used for scope construction. + scope (str, optional): The authentication scope, automatically constructed if not provided. + token_url (str): The URL for obtaining access tokens. + + Error: + ValueError: Raised when tsg_id is missing and scope is not provided. + + """ + client_id: str client_secret: str tsg_id: str @@ -11,12 +30,12 @@ class AuthRequest(BaseModel): default="https://auth.apps.paloaltonetworks.com/am/oauth2/access_token" ) - @model_validator(mode='before') + @model_validator(mode="before") @classmethod def construct_scope(cls, values): - if values.get('scope') is None: - tsg_id = values.get('tsg_id') + if values.get("scope") is None: + tsg_id = values.get("tsg_id") if tsg_id is None: - raise ValueError('tsg_id is required to construct scope') - values['scope'] = f"tsg_id:{tsg_id}" + raise ValueError("tsg_id is required to construct scope") + values["scope"] = f"tsg_id:{tsg_id}" return values diff --git a/pan_scm_sdk/models/errors.py b/pan_scm_sdk/models/errors.py new file mode 100644 index 00000000..e69de29b diff --git a/pan_scm_sdk/resources/address.py b/pan_scm_sdk/resources/address.py new file mode 100644 index 00000000..54731ed3 --- /dev/null +++ b/pan_scm_sdk/resources/address.py @@ -0,0 +1,75 @@ +# pan_scm_sdk/resources/address.py + +from typing import List, Optional +from pan_scm_sdk.client import APIClient +from pan_scm_sdk.models.address import Address +from pan_scm_sdk.endpoints.addresses import ADDRESSES_ENDPOINTS +from pan_scm_sdk.utils.logging import setup_logger + +logger = setup_logger(__name__) + + +class AddressClient: + """ + A client class for managing addresses in Palo Alto Networks' Strata Cloud Manager. + + This class provides methods to list, get, create, update, and delete addresses + using the Strata Cloud Manager API. + + Attributes: + api_client (APIClient): An instance of the APIClient for making API requests. + + Error: + APIError: May be raised for any API-related errors during requests. + + Return: + Various methods return Address objects or lists of Address objects. + """ + + def __init__(self, api_client: APIClient): + self.api_client = api_client + + def list_addresses( + self, + name: Optional[str] = None, + folder: str = "Shared", + snippet: Optional[str] = None, + device: Optional[str] = None, + offset: int = 0, + limit: int = 200, + ) -> List[Address]: + endpoint = ADDRESSES_ENDPOINTS["list_addresses"] + params = { + "name": name, + "folder": folder, + "snippet": snippet, + "device": device, + "offset": offset, + "limit": limit, + } + params = {k: v for k, v in params.items() if v is not None} + + response = self.api_client.get(endpoint, params=params) + addresses = [Address(**item) for item in response.get("data", [])] + return addresses + + def get_address(self, address_id: str) -> Address: + endpoint = ADDRESSES_ENDPOINTS["get_address"].format(id=address_id) + response = self.api_client.get(endpoint) + return Address(**response) + + def create_address(self, address: Address) -> Address: + endpoint = ADDRESSES_ENDPOINTS["create_address"] + payload = address.model_dump(exclude_unset=True) + response = self.api_client.post(endpoint, json=payload) + return Address(**response) + + def update_address(self, address_id: str, address: Address) -> Address: + endpoint = ADDRESSES_ENDPOINTS["update_address"].format(id=address_id) + payload = address.model_dump(exclude_unset=True) + response = self.api_client.put(endpoint, json=payload) + return Address(**response) + + def delete_address(self, address_id: str) -> None: + endpoint = ADDRESSES_ENDPOINTS["delete_address"].format(id=address_id) + self.api_client.delete(endpoint) diff --git a/pan_scm_sdk/utils/logging.py b/pan_scm_sdk/utils/logging.py index b0c7515d..1bdfcef9 100644 --- a/pan_scm_sdk/utils/logging.py +++ b/pan_scm_sdk/utils/logging.py @@ -3,7 +3,21 @@ import logging import sys + def setup_logger(name: str) -> logging.Logger: + """ + Set up and return a configured logger with the given name. + + This function creates a logger with DEBUG level, adds a console handler, + and sets a formatter for consistent log message formatting. + + Attributes: + name (str): The name to be assigned to the logger. + + Return: + logger (logging.Logger): A configured logger instance. + """ + """Set up and return a logger with the given name.""" logger = logging.getLogger(name) logger.setLevel(logging.DEBUG) @@ -13,7 +27,9 @@ def setup_logger(name: str) -> logging.Logger: ch.setLevel(logging.DEBUG) # Formatter - formatter = logging.Formatter('%(asctime)s - %(name)s - %(levelname)s - %(message)s') + formatter = logging.Formatter( + "%(asctime)s - %(name)s - %(levelname)s - %(message)s" + ) ch.setFormatter(formatter) # Add handler to logger From 7e83b7940bac1a72b2186edace83fd827cf3c00c Mon Sep 17 00:00:00 2001 From: Calvin Remsburg Date: Wed, 9 Oct 2024 20:52:22 -0500 Subject: [PATCH 07/11] Add endpoints for addresses, applications, and address groups Introduced three new modules containing endpoint definitions for addresses, applications, and address groups within the pan_scm_sdk package. These modules include CRUD endpoint mappings to streamline API interactions for these entities. --- pan_scm_sdk/endpoints/address_groups.py | 9 +++++++++ pan_scm_sdk/endpoints/addresses.py | 9 +++++++++ pan_scm_sdk/endpoints/applications.py | 9 +++++++++ pan_scm_sdk/models/address_group.py | 0 pan_scm_sdk/models/application.py | 0 pan_scm_sdk/models/application_filter.py | 0 6 files changed, 27 insertions(+) create mode 100644 pan_scm_sdk/endpoints/address_groups.py create mode 100644 pan_scm_sdk/endpoints/addresses.py create mode 100644 pan_scm_sdk/endpoints/applications.py create mode 100644 pan_scm_sdk/models/address_group.py create mode 100644 pan_scm_sdk/models/application.py create mode 100644 pan_scm_sdk/models/application_filter.py diff --git a/pan_scm_sdk/endpoints/address_groups.py b/pan_scm_sdk/endpoints/address_groups.py new file mode 100644 index 00000000..cb8414bd --- /dev/null +++ b/pan_scm_sdk/endpoints/address_groups.py @@ -0,0 +1,9 @@ +# pan_scm_sdk/endpoints/addresses.py + +ADDRESS_GROUPS_ENDPOINTS = { + "list_address_groups": "/config/objects/v1/address-groups", + "create_address_groups": "/config/objects/v1/address-groups", + "get_address_group": "/config/objects/v1/address-groups/{id}", + "update_address_group": "/config/objects/v1/address-groups/{id}", + "delete_address_group": "/config/objects/v1/address-groups/{id}", +} diff --git a/pan_scm_sdk/endpoints/addresses.py b/pan_scm_sdk/endpoints/addresses.py new file mode 100644 index 00000000..413384f9 --- /dev/null +++ b/pan_scm_sdk/endpoints/addresses.py @@ -0,0 +1,9 @@ +# pan_scm_sdk/endpoints/addresses.py + +ADDRESSES_ENDPOINTS = { + "list_addresses": "/config/objects/v1/addresses", + "create_address": "/config/objects/v1/addresses", + "get_address": "/config/objects/v1/addresses/{id}", + "update_address": "/config/objects/v1/addresses/{id}", + "delete_address": "/config/objects/v1/addresses/{id}", +} diff --git a/pan_scm_sdk/endpoints/applications.py b/pan_scm_sdk/endpoints/applications.py new file mode 100644 index 00000000..f2e7c945 --- /dev/null +++ b/pan_scm_sdk/endpoints/applications.py @@ -0,0 +1,9 @@ +# pan_scm_sdk/endpoints/applications.py + +APPLICATIONS_ENDPOINTS = { + "list_applications": "/config/objects/v1/applications", + "create_applications": "/config/objects/v1/applications", + "get_application": "/config/objects/v1/applications/{id}", + "update_application": "/config/objects/v1/addresses/{id}", + "delete_application": "/config/objects/v1/addresses/{id}", +} diff --git a/pan_scm_sdk/models/address_group.py b/pan_scm_sdk/models/address_group.py new file mode 100644 index 00000000..e69de29b diff --git a/pan_scm_sdk/models/application.py b/pan_scm_sdk/models/application.py new file mode 100644 index 00000000..e69de29b diff --git a/pan_scm_sdk/models/application_filter.py b/pan_scm_sdk/models/application_filter.py new file mode 100644 index 00000000..e69de29b From 5de7836bdfea7e19c0d5f0aeeff19b5ed4e8da28 Mon Sep 17 00:00:00 2001 From: Calvin Remsburg Date: Wed, 9 Oct 2024 20:52:29 -0500 Subject: [PATCH 08/11] Add detailed docstring to OAuth2Client class Enhanced OAuth2Client with an extensive docstring explaining its purpose, attributes, errors, and return values. This improves code maintainability and clarity for future developers. --- pan_scm_sdk/auth/oauth2.py | 29 ++++++++++++++++++++++++----- 1 file changed, 24 insertions(+), 5 deletions(-) diff --git a/pan_scm_sdk/auth/oauth2.py b/pan_scm_sdk/auth/oauth2.py index c85e2ba2..04a43dbe 100644 --- a/pan_scm_sdk/auth/oauth2.py +++ b/pan_scm_sdk/auth/oauth2.py @@ -10,7 +10,26 @@ logger = setup_logger(__name__) + class OAuth2Client: + """ + A client for OAuth2 authentication with Palo Alto Networks' Strata Cloud Manager. + + This class handles OAuth2 token acquisition, validation, and refresh for authenticating + with Palo Alto Networks' services. It supports token decoding and expiration checking. + + Attributes: + auth_request (AuthRequest): An object containing authentication parameters. + session (OAuth2Session): The authenticated OAuth2 session. + signing_key (PyJWK): The key used for verifying the JWT token. + + Error: + ExpiredSignatureError: Raised when the token has expired. + + Return: + payload (dict): Decoded JWT token payload when using decode_token method. + """ + def __init__(self, auth_request: AuthRequest): self.auth_request = auth_request self.session = self._create_session() @@ -27,15 +46,15 @@ def _create_session(self): client_secret=self.auth_request.client_secret, scope=self.auth_request.scope, include_client_id=True, - client_kwargs={'tsg_id': self.auth_request.tsg_id} + client_kwargs={"tsg_id": self.auth_request.tsg_id}, ) logger.debug(f"Token fetched successfully. {token}") return oauth def _get_signing_key(self): - jwks_uri = "/".join( - self.auth_request.token_url.split("/")[:-1] - ) + "/connect/jwk_uri" + jwks_uri = ( + "/".join(self.auth_request.token_url.split("/")[:-1]) + "/connect/jwk_uri" + ) jwks_client = PyJWKClient(jwks_uri) signing_key = jwks_client.get_signing_key_from_jwt( self.session.token["access_token"] @@ -76,7 +95,7 @@ def refresh_token(self): client_secret=self.auth_request.client_secret, scope=self.auth_request.scope, include_client_id=True, - client_kwargs={'tsg_id': self.auth_request.tsg_id} + client_kwargs={"tsg_id": self.auth_request.tsg_id}, ) logger.debug(f"Token refreshed successfully. {token}") self.signing_key = self._get_signing_key() From 6eea3a302b37d56662ecc162a3c9ccb925239057 Mon Sep 17 00:00:00 2001 From: Calvin Remsburg Date: Wed, 9 Oct 2024 20:55:25 -0500 Subject: [PATCH 09/11] Add Mermaid class diagram for SDK documentation Introduce a new Mermaid file to visualize the class relationships within the SDK. The diagram includes various nodes representing classes and their methods, enhancing the documentation's comprehensibility. --- docs/mermaid/sdk.mmd | 330 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 330 insertions(+) create mode 100644 docs/mermaid/sdk.mmd diff --git a/docs/mermaid/sdk.mmd b/docs/mermaid/sdk.mmd new file mode 100644 index 00000000..dbd4efb0 --- /dev/null +++ b/docs/mermaid/sdk.mmd @@ -0,0 +1,330 @@ +classDiagram + direction BT + class BaseException { + args + __cause__ + __context__ + __suppress_context__ + __traceback__ + __init__(self, *args: object) + __setstate__(self, __state: dict[str, Any] | None) + with_traceback(self, __tb: TracebackType | None) + } + class Exception + class object { + __doc__ + __dict__ + __module__ + __annotations__ + __class__(self) + __class__(self, __type: type[object]) + __init__(self) + __new__(cls) + __setattr__(self, __name: str, __value: Any) + __delattr__(self, __name: str) + __eq__(self, __value: object) + __ne__(self, __value: object) + __str__(self) + __repr__(self) + __hash__(self) + __format__(self, __format_spec: str) + __getattribute__(self, __name: str) + __sizeof__(self) + __reduce__(self) + __reduce_ex__(self, __protocol: SupportsIndex) + __dir__(self) + __init_subclass__(cls) + __subclasshook__(cls, __subclass: type) + } + class node3 { + auth_request + signing_key + session + __init__(self, auth_request: AuthRequest) + _create_session(self) + _get_signing_key(self) + decode_token(self) + is_expired(self) + refresh_token(self) + } + class node6 { + session + api_base_url + oauth_client + __init__( + self, + client_id: str, + client_secret: str, + tsg_id: str, + api_base_url: str = "https://api.strata.paloaltonetworks.com", + ) + request(self, method: str, endpoint: str, **kwargs) + get(self, endpoint: str, **kwargs) + post(self, endpoint: str, **kwargs) + put(self, endpoint: str, **kwargs) + delete(self, endpoint: str, **kwargs) + } + class node1 { + client_id + client_secret + tsg_id + scope + token_url + construct_scope(cls, values) + } + class node5 { + parameters_str + combined_parameters + parent_namespace + __pydantic_generic_metadata__ + config_wrapper + original_model_post_init + parent_parameters + BaseModel + types_namespace + class_vars + error_message + __pydantic_decorators__ + model_computed_fields + base_private_attributes + private_attributes + base_field_names + cls + mro + generic_type_label + __pydantic_complete__ + __pydantic_post_init__ + missing_parameters + bases_str + __pydantic_custom_init__ + parameters + __pydantic_parent_namespace__ + __new__( + mcs, + cls_name: str, + bases: tuple[type[Any], ...], + namespace: dict[str, Any], + __pydantic_generic_metadata__: PydanticGenericMetadata | None = None, + __pydantic_reset_parent_namespace__: bool = True, + _create_model_module: str | None = None, + **kwargs: Any, + ) + __getattr__(self, item: str) + __prepare__(cls, *args: Any, **kwargs: Any) + __instancecheck__(self, instance: Any) + _collect_bases_data(bases: tuple[type[Any], ...]) + __fields__(self) + __dir__(self) + } + class node4 { + __pydantic_parent_namespace__ + model_config + model_fields + model_computed_fields + __class_vars__ + __private_attributes__ + __signature__ + __pydantic_complete__ + __pydantic_core_schema__ + __pydantic_custom_init__ + __pydantic_decorators__ + __pydantic_generic_metadata__ + __pydantic_parent_namespace__ + __pydantic_post_init__ + __pydantic_root_model__ + __pydantic_serializer__ + __pydantic_validator__ + __pydantic_extra__ + __pydantic_fields_set__ + __pydantic_private__ + __pydantic_core_schema__ + __pydantic_validator__ + __pydantic_serializer__ + __slots__ + __pydantic_base_init__ + __repr_name__ + __repr_str__ + __pretty__ + __rich_repr__ + __init__(self, /, **data: Any) + model_extra(self) + model_fields_set(self) + model_construct(cls, _fields_set: set[str] | None = None, **values: Any) + model_copy(self, *, update: dict[str, Any] | None = None, deep: bool = False) + model_dump( + self, + *, + mode: Literal['json', 'python'] | str = 'python', + include: IncEx | None = None, + exclude: IncEx | None = None, + context: Any | None = None, + by_alias: bool = False, + exclude_unset: bool = False, + exclude_defaults: bool = False, + exclude_none: bool = False, + round_trip: bool = False, + warnings: bool | Literal['none', 'warn', 'error'] = True, + serialize_as_any: bool = False, + ) + model_dump_json( + self, + *, + indent: int | None = None, + include: IncEx | None = None, + exclude: IncEx | None = None, + context: Any | None = None, + by_alias: bool = False, + exclude_unset: bool = False, + exclude_defaults: bool = False, + exclude_none: bool = False, + round_trip: bool = False, + warnings: bool | Literal['none', 'warn', 'error'] = True, + serialize_as_any: bool = False, + ) + model_json_schema( + cls, + by_alias: bool = True, + ref_template: str = DEFAULT_REF_TEMPLATE, + schema_generator: type[GenerateJsonSchema] = GenerateJsonSchema, + mode: JsonSchemaMode = 'validation', + ) + model_parametrized_name(cls, params: tuple[type[Any], ...]) + model_post_init(self, __context: Any) + model_rebuild( + cls, + *, + force: bool = False, + raise_errors: bool = True, + _parent_namespace_depth: int = 2, + _types_namespace: dict[str, Any] | None = None, + ) + model_validate( + cls, + obj: Any, + *, + strict: bool | None = None, + from_attributes: bool | None = None, + context: Any | None = None, + ) + model_validate_json( + cls, + json_data: str | bytes | bytearray, + *, + strict: bool | None = None, + context: Any | None = None, + ) + model_validate_strings( + cls, + obj: Any, + *, + strict: bool | None = None, + context: Any | None = None, + ) + __get_pydantic_core_schema__(cls, source: type[BaseModel], handler: GetCoreSchemaHandler, /) + __get_pydantic_json_schema__( + cls, + core_schema: CoreSchema, + handler: GetJsonSchemaHandler, + /, + ) + __pydantic_init_subclass__(cls, **kwargs: Any) + __class_getitem__( + cls, typevar_values: type[Any] | tuple[type[Any], ...] + ) + __copy__(self) + __deepcopy__(self, memo: dict[int, Any] | None = None) + __getattr__(self, item: str) + __setattr__(self, name: str, value: Any) + __delattr__(self, item: str) + _check_frozen(self, name: str, value: Any) + __getstate__(self) + __setstate__(self, state: dict[Any, Any]) + __eq__(self, other: Any) + __init_subclass__(cls, **kwargs: Unpack[ConfigDict]) + __iter__(self) + __repr__(self) + __repr_args__(self) + __str__(self) + __fields__(self) + __fields_set__(self) + dict(# noqa: D102 + self, + *, + include: IncEx | None = None, + exclude: IncEx | None = None, + by_alias: bool = False, + exclude_unset: bool = False, + exclude_defaults: bool = False, + exclude_none: bool = False, + ) + json(# noqa: D102 + self, + *, + include: IncEx | None = None, + exclude: IncEx | None = None, + by_alias: bool = False, + exclude_unset: bool = False, + exclude_defaults: bool = False, + exclude_none: bool = False, + encoder: Callable[[Any], Any] | None = PydanticUndefined, # type: ignore[assignment] + models_as_dict: bool = PydanticUndefined, # type: ignore[assignment] + **dumps_kwargs: Any, + ) + parse_obj(cls, obj: Any) + parse_raw(# noqa: D102 + cls, + b: str | bytes, + *, + content_type: str | None = None, + encoding: str = 'utf8', + proto: DeprecatedParseProtocol | None = None, + allow_pickle: bool = False, + ) + parse_file(# noqa: D102 + cls, + path: str | Path, + *, + content_type: str | None = None, + encoding: str = 'utf8', + proto: DeprecatedParseProtocol | None = None, + allow_pickle: bool = False, + ) + from_orm(cls, obj: Any) + construct(cls, _fields_set: set[str] | None = None, **values: Any) + copy( + self, + *, + include: AbstractSetIntStr | MappingIntStrAny | None = None, + exclude: AbstractSetIntStr | MappingIntStrAny | None = None, + update: Dict[str, Any] | None = None, # noqa UP006 + deep: bool = False, + ) + schema(# noqa: D102 + cls, by_alias: bool = True, ref_template: str = DEFAULT_REF_TEMPLATE + ) + schema_json(# noqa: D102 + cls, *, by_alias: bool = True, ref_template: str = DEFAULT_REF_TEMPLATE, **dumps_kwargs: Any + ) + validate(cls, value: Any) + update_forward_refs(cls, **localns: Any) + _iter(self, *args: Any, **kwargs: Any) + _copy_and_set_values(self, *args: Any, **kwargs: Any) + _get_value(cls, *args: Any, **kwargs: Any) + _calculate_keys(self, *args: Any, **kwargs: Any) + } + class node2 { + __hash__(self) + } + class node7 { + __iter__(self) + } + + object --> BaseException + BaseException --> Exception + node2 ..> object + object --> node3 + object --> node6 + node4 --> node1 + object --> node4 + node5 "isinstanceof" ..> node4 + node7 ..> node4 From 155f7d7a544c9bc74c6aee15a6a62b829f0f526c Mon Sep 17 00:00:00 2001 From: Calvin Remsburg Date: Wed, 9 Oct 2024 21:15:00 -0500 Subject: [PATCH 10/11] Refactor `id` field to be optional in Address model Previously, the `id` field was a required attribute for the Address model. This change makes the `id` field optional, providing more flexibility for instances where an ID may not yet be assigned. --- pan_scm_sdk/models/address.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/pan_scm_sdk/models/address.py b/pan_scm_sdk/models/address.py index 8aaea11c..9ab15081 100644 --- a/pan_scm_sdk/models/address.py +++ b/pan_scm_sdk/models/address.py @@ -35,11 +35,6 @@ class Address(BaseModel): ) # Required fields - id: str = Field( - ..., - description="The UUID of the address object", - examples=["123e4567-e89b-12d3-a456-426655440000"], - ) name: str = Field( ..., max_length=63, @@ -47,6 +42,11 @@ class Address(BaseModel): ) # Optional fields + id: Optional[str] = Field( + None, + description="The UUID of the address object", + examples=["123e4567-e89b-12d3-a456-426655440000"], + ) description: Optional[str] = Field( None, max_length=1023, From 181fbeab618b4bd1f5abe3cb3f82c79fcc3ba62f Mon Sep 17 00:00:00 2001 From: Calvin Remsburg Date: Wed, 9 Oct 2024 21:31:53 -0500 Subject: [PATCH 11/11] Update README.md with detailed usage examples and features Enhanced README.md to include comprehensive usage examples for authentication, creating, listing, updating, and deleting address objects. Added detailed descriptions of key features such as OAuth2 authentication, resource management, data validation, and exception handling. --- README.md | 88 +++++++++++++++++++++++++++++++++++++++++++------------ 1 file changed, 69 insertions(+), 19 deletions(-) diff --git a/README.md b/README.md index a60fceb5..02655346 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Strata Cloud Manager SDK -![Banner Image](https://raw.githubusercontent.com/cdot65/pan-scm-sdk/refs/heads/main/docs/images/logo.svg) +![Banner Image](https://raw.githubusercontent.com/cdot65/pan-scm-sdk/main/docs/images/logo.svg) [![Build Status](https://github.com/cdot65/pan-scm-sdk/actions/workflows/ci.yml/badge.svg)](https://github.com/cdot65/pan-scm-sdk/actions/workflows/ci.yml) [![PyPI version](https://badge.fury.io/py/pan-scm-sdk.svg)](https://badge.fury.io/py/pan-scm-sdk) @@ -14,18 +14,23 @@ Python SDK for Palo Alto Networks Strata Cloud Manager. - [Features](#features) - [Installation](#installation) - [Usage](#usage) - - [Authentication](#authentication) - - [Create Address Groups](#create-address-groups) - - [Create Address Objects](#create-address-objects) + - [Authentication](#authentication) + - [Creating Address Objects](#creating-address-objects) + - [Listing Addresses](#listing-addresses) + - [Updating an Address](#updating-an-address) + - [Deleting an Address](#deleting-an-address) - [Contributing](#contributing) - [License](#license) - [Support](#support) ## Features -- **feature1**: will add later -- **feature2**: will add later -- **feature3**: will add later +- **OAuth2 Authentication**: Securely authenticate with the Strata Cloud Manager API using OAuth2 client credentials + flow. +- **Resource Management**: Create, read, update, and delete configuration objects such as addresses. +- **Data Validation**: Utilize Pydantic models for data validation and serialization. +- **Exception Handling**: Comprehensive error handling with custom exceptions for API errors. +- **Extensibility**: Designed for easy extension to support additional resources and endpoints. ## Installation @@ -41,30 +46,75 @@ pip install pan-scm-sdk ## Usage -Will add later: - ### Authentication -Will add later: +Before interacting with the SDK, you need to authenticate using your Strata Cloud Manager credentials. +```python +from pan_scm_sdk.client import APIClient + +# Initialize the API client with your credentials +api_client = APIClient( + client_id="your_client_id", + client_secret="your_client_secret", + tsg_id="your_tsg_id", +) + +# The api_client is now ready to use ``` -will provide examples later -``` -### Create Address Groups +### Creating Address Objects + +```python +from pan_scm_sdk.resources.address import AddressClient +from pan_scm_sdk.models.address import Address + +# Create an AddressClient instance +address_client = AddressClient(api_client) -Will add later: +# Define a new address object +address = Address( + name="MyAddress", + ip_netmask="192.168.1.1/32", + folder="Shared", +) +# Create the address in Strata Cloud Manager +created_address = address_client.create_address(address) +print(f"Created address with ID: {created_address.id}") ``` -will provide examples later + +### Listing Addresses + +```python +# List addresses with optional filtering +addresses = address_client.list_addresses(limit=10) +for addr in addresses: + print(f"Address ID: {addr.id}, Name: {addr.name}, IP: {addr.ip_netmask}") ``` -### Create Address Objects +### Updating an Address + +```python +# Retrieve an existing address +address_id = "123e4567-e89b-12d3-a456-426655440000" +address = address_client.get_address(address_id) -Will add later: +# Update the address properties +address.description = "Updated description" +# Send the update to Strata Cloud Manager +updated_address = address_client.update_address(address_id, address) +print(f"Updated address with ID: {updated_address.id}") ``` -will provide examples later + +### Deleting an Address + +```python +# Delete an address by ID +address_id = "123e4567-e89b-12d3-a456-426655440000" +address_client.delete_address(address_id) +print(f"Deleted address with ID: {address_id}") ``` ## Contributing @@ -89,4 +139,4 @@ For support and questions, please refer to the [SUPPORT.md](./SUPPORT.md) file i --- -*Detailed documentation will be provided on our GitHub Pages site soon.* \ No newline at end of file +*Detailed documentation is available on our [GitHub Pages site](https://cdot65.github.io/pan-scm-sdk/).* \ No newline at end of file