diff --git a/Cisco Secure Access/Samples/Identities/sgt-sync/README.md b/Cisco Secure Access/Samples/Identities/sgt-sync/README.md new file mode 100644 index 0000000..e9b33de --- /dev/null +++ b/Cisco Secure Access/Samples/Identities/sgt-sync/README.md @@ -0,0 +1,21 @@ +A simple sync tool to bring Security Group Tags from ISE to Secure Access. + +## Usage +main.py [-h] [--list-ise | --list-sa | --list-sa-inactive | --diff-only] + +options: + -h, --help show this help message and exit + --list-ise List all Security Group Tags found in Cisco Identity Services Engine (ISE). + --list-sa List all Security Group Tags found in Cisco Secure Access (active and inactive). + --list-sa-inactive List only the INACTIVE Security Group Tags found in Cisco Secure Access. + --diff-only Show the difference between ISE and Secure Access SGTs without performing any synchronization (no changes applied). + +## Environmental Variables +| Variable | Comment | +|----------------|-------------------------| +ISE-SERVER | IP address or FQDN +ISE-USER | ISE ERS Admin Username +ISE-PASS | ISE ERS Admin Password +SA-KEY | Secure Access API Key +SA-SECRET | Secure Access API Secret + diff --git a/Cisco Secure Access/Samples/Identities/sgt-sync/main.py b/Cisco Secure Access/Samples/Identities/sgt-sync/main.py new file mode 100644 index 0000000..d9ba847 --- /dev/null +++ b/Cisco Secure Access/Samples/Identities/sgt-sync/main.py @@ -0,0 +1,152 @@ +""" +Copyright (c) 2025 Cisco and/or its affiliates. +This software is licensed to you under the terms of the Cisco Sample +Code License, Version 1.1 (the "License"). You may obtain a copy of the +License at + +https://developer.cisco.com/docs/licenses + +All use of the material herein must be in accordance with the terms of +the License. All rights not expressly granted by the License are +reserved. Unless required by applicable law or agreed to separately in +writing, software distributed under the License is distributed on an "AS +IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express +or implied. +""" + +# main.py +import sys +import argparse +from sgt_sync.config import Config +from sgt_sync.logging_config import setup_logging +from sgt_sync.clients.ise_client import IseClient, IseClientError +from sgt_sync.clients.secure_access_client import ( + SecureAccessClient, + SecureAccessClientError, +) +from sgt_sync.synchronizer import SgtSynchronizer +from sgt_sync.models.sgt import SecurityGroupTag + +# Configure logging for the entire application +logger = setup_logging() + + +def print_sgts(title: str, sgts: list[SecurityGroupTag]): + """Helper function to print a list of SGTs in a readable format.""" + if not sgts: + logger.info(f"No {title} SGTs found.") + return + + logger.info(f"\n--- {title} ({len(sgts)} SGTs) ---") + for sgt in sgts: + logger.info( + f" Key: {sgt.key}, Label: '{sgt.label}', Tag ID: {sgt.tag_id}, Status: {sgt.status}" + ) + logger.info(f"--- End of {title} ---") + + +def main(): + """Main function to run the SGT synchronization or perform CLI actions.""" + parser = argparse.ArgumentParser( + description="Synchronize Security Group Tags from ISE to Cisco Secure Access, or perform diagnostic actions." + ) + + # Create a mutually exclusive group for commands that should not run together + group = parser.add_mutually_exclusive_group() + group.add_argument( + "--list-ise", + action="store_true", + help="List all Security Group Tags found in Cisco Identity Services Engine (ISE).", + ) + group.add_argument( + "--list-sa", + action="store_true", + help="List all Security Group Tags found in Cisco Secure Access (active and inactive).", + ) + group.add_argument( + "--list-sa-inactive", + action="store_true", + help="List only the INACTIVE Security Group Tags found in Cisco Secure Access.", + ) + group.add_argument( + "--diff-only", + action="store_true", + help="Show the difference between ISE and Secure Access SGTs without performing any synchronization (no changes applied).", + ) + args = parser.parse_args() + + try: + # Load and validate configuration + Config.validate() + config = Config() + logger.info("Configuration loaded and validated.") + + # Initialize clients + ise_client = IseClient(config) + sa_client = SecureAccessClient(config) + logger.info("API clients initialized.") + + # Handle CLI arguments + if args.list_ise: + logger.info("Fetching SGTs from ISE...") + ise_sgts = ise_client.get_sgts() + print_sgts("ISE SGTs", ise_sgts) + sys.exit(0) + + elif args.list_sa: + logger.info("Fetching SGTs from Secure Access (all statuses)...") + sa_sgts = sa_client.get_sgts() + print_sgts("Secure Access SGTs (All)", sa_sgts) + sys.exit(0) + + elif args.list_sa_inactive: + logger.info("Fetching INACTIVE SGTs from Secure Access...") + all_sa_sgts = sa_client.get_sgts() + inactive_sa_sgts = [sgt for sgt in all_sa_sgts if sgt.status == "inactive"] + print_sgts("Secure Access SGTs (Inactive)", inactive_sa_sgts) + sys.exit(0) + + elif args.diff_only: + logger.info( + "Performing SGT difference analysis (diff-only mode). No changes will be applied." + ) + ise_sgts = ise_client.get_sgts() + sa_sgts = sa_client.get_sgts() + + synchronizer = SgtSynchronizer(ise_client, sa_client) + sgts_to_add_update, sgts_to_mark_inactive = synchronizer.diff_sgts( + ise_sgts, sa_sgts + ) + + print_sgts( + "SGTs to Add/Update in Secure Access (would be added/modified)", + sgts_to_add_update, + ) + print_sgts( + "SGTs to Mark Inactive in Secure Access (would be set to inactive)", + sgts_to_mark_inactive, + ) + sys.exit(0) + + # Full synchronization if no specific CLI arguments are provided + else: + logger.info( + "No specific CLI arguments provided. Proceeding with full SGT synchronization." + ) + synchronizer = SgtSynchronizer(ise_client, sa_client) + synchronizer.sync_sgts() + logger.info("SGT synchronization finished successfully.") + + except ValueError as e: + logger.critical(f"Configuration Error: {e}") + sys.exit(1) + except (IseClientError, SecureAccessClientError) as e: + logger.critical(f"API Client Error: {e}") + sys.exit(1) + except Exception as e: + logger.critical(f"An unexpected error occurred: {e}", exc_info=True) + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/Cisco Secure Access/Samples/Identities/sgt-sync/pyproject.toml b/Cisco Secure Access/Samples/Identities/sgt-sync/pyproject.toml new file mode 100644 index 0000000..82df65c --- /dev/null +++ b/Cisco Secure Access/Samples/Identities/sgt-sync/pyproject.toml @@ -0,0 +1,12 @@ +[project] +name = "sgt-sync" +version = "0.1.0" +description = "A simple proof-of-concept sync tool to bring Security Group Tags from ISE to Secure Access" +readme = "README.md" +requires-python = ">=3.13" +dependencies = [ + "argparse>=1.4.0", + "dotenv>=0.9.9", + "httpx>=0.28.1", + "requests-auth>=8.0.0", +] diff --git a/Cisco Secure Access/Samples/Identities/sgt-sync/sgt-sync/clients/ise_client.py b/Cisco Secure Access/Samples/Identities/sgt-sync/sgt-sync/clients/ise_client.py new file mode 100644 index 0000000..d70f5a3 --- /dev/null +++ b/Cisco Secure Access/Samples/Identities/sgt-sync/sgt-sync/clients/ise_client.py @@ -0,0 +1,101 @@ +# sgt_sync/clients/ise_client.py +import httpx +import json +import logging +from requests.auth import HTTPBasicAuth +from typing import List + +from ..config import Config +from ..models.sgt import SecurityGroupTag + +logger = logging.getLogger("sgt_sync.ise_client") + +class IseClientError(Exception): + """Custom exception for ISE client errors.""" + pass + +class IseClient: + """ + Client for interacting with Cisco Identity Services Engine (ISE) ERS APIs. + Handles SGT fetching. + """ + def __init__(self, config: Config): + self.config = config + self.auth = HTTPBasicAuth(self.config.ISE_USER, self.config.ISE_PASS) + self.headers = { + "Accept": "application/JSON", + "Content-Type": "application/JSON", + } + self.client = httpx.Client(verify=self.config.VERIFY_SSL) + + def get_sgts(self) -> List[SecurityGroupTag]: + """ + Retrieve all Security Group Tags from ISE using the ERS API. + Handles pagination and fetches full SGT details by following links. + """ + logger.info(f"Retrieving ISE SGTs from {self.config.BASE_ISE_ERS_URL}/sgt...") + ise_sgt_list: List[SecurityGroupTag] = [] + url = f"{self.config.BASE_ISE_ERS_URL}/sgt" + + current_start_index = 0 + page_size = 100 + total_sgts = -1 + + try: + while total_sgts == -1 or current_start_index < total_sgts: + params = {"size": page_size, "startIndex": current_start_index} + logger.debug( + f"Fetching ISE SGTs with startIndex={current_start_index}, size={page_size}" + ) + + response = self.client.get(url, auth=self.auth, params=params, headers=self.headers) + response.raise_for_status() + + data = response.json() + search_result = data.get("SearchResult", {}) + resources = search_result.get("resources", []) + + if not resources: + logger.debug("No more resources found in ISE response.") + break + + for resource in resources: + sgt_detail_link = resource.get("link", {}).get("href") + + if sgt_detail_link: + sgt_detail_response = self.client.get( + sgt_detail_link, auth=self.auth, headers=self.headers + ) + sgt_detail_response.raise_for_status() + + sgt_data = sgt_detail_response.json().get("Sgt") + + if sgt_data: + try: + ise_sgt_list.append(SecurityGroupTag.from_ise_data(sgt_data)) + except ValueError as ve: + logger.warning(f"Skipping malformed ISE SGT data: {sgt_data} - {ve}") + else: + logger.warning( + f"Could not find link for ISE SGT resource: {resource}" + ) + + total_sgts = search_result.get("total", len(ise_sgt_list)) + current_start_index += len(resources) + logger.debug( + f"Current ISE SGTs retrieved: {len(ise_sgt_list)}, Total expected: {total_sgts}" + ) + + logger.info(f"Successfully retrieved {len(ise_sgt_list)} SGTs from ISE.") + return ise_sgt_list + except httpx.HTTPStatusError as e: + logger.error( + f"Failed to retrieve ISE SGTs (HTTP Status {e.response.status_code}): {e.response.text}" + ) + raise IseClientError("Failed to retrieve ISE SGTs.") from e + except json.JSONDecodeError as e: + logger.error(f"Failed to decode JSON response from ISE: {e}") + raise IseClientError("Invalid JSON response from ISE.") from e + except httpx.RequestError as e: + logger.error(f"An error occurred while requesting ISE SGTs: {e}") + raise IseClientError("Network error during ISE SGT retrieval.") from e \ No newline at end of file diff --git a/Cisco Secure Access/Samples/Identities/sgt-sync/sgt-sync/clients/secure_access_client.py b/Cisco Secure Access/Samples/Identities/sgt-sync/sgt-sync/clients/secure_access_client.py new file mode 100644 index 0000000..3db0455 --- /dev/null +++ b/Cisco Secure Access/Samples/Identities/sgt-sync/sgt-sync/clients/secure_access_client.py @@ -0,0 +1,185 @@ +# sgt_sync/clients/secure_access_client.py +import httpx +import json +import logging +import time +from requests.auth import HTTPBasicAuth +from typing import List, Dict, Optional + +from ..config import Config +from ..models.sgt import SecurityGroupTag + +logger = logging.getLogger("sgt_sync.sa_client") + +class SecureAccessClientError(Exception): + """Custom exception for Secure Access client errors.""" + pass + +class SecureAccessClient: + """ + Client for interacting with Cisco Secure Access APIs. + Handles token retrieval, SGT fetching, and SGT updates. + """ + def __init__(self, config: Config): + self.config = config + self._token: Optional[str] = None + self._token_expiry: Optional[float] = None + self.client = httpx.Client() + + def _get_token(self) -> str: + """ + Obtain an OAuth 2.0 token from Cisco Secure Access API. + Caches the token for subsequent requests and checks for expiration. + """ + # Check if a token exists and is still valid + if self._token and self._token_expiry and time.time() < self._token_expiry: + logger.debug("Using cached Secure Access token.") + return self._token + + logger.info("Retrieving Secure Access token (or refreshing expired token)...") + auth = HTTPBasicAuth(self.config.SA_KEY, self.config.SA_SECRET) + + try: + response = self.client.post(self.config.BASE_SA_AUTH_URL, auth=auth) + response.raise_for_status() + token_data = response.json() + self._token = token_data.get("access_token") + expires_in = token_data.get("expires_in") + + if not self._token: + raise SecureAccessClientError("Access token not found in response.") + + if expires_in: + # Set expiry time a bit before actual expiration to account for network latency/processing time + self._token_expiry = time.time() + expires_in - 60 + logger.info(f"Secure Access token retrieved successfully, expires in {expires_in} seconds.") + else: + self._token_expiry = None + logger.warning("Secure Access token retrieved, but no 'expires_in' field found. Token validity not tracked.") + + return self._token + except httpx.HTTPStatusError as e: + logger.error( + f"Failed to retrieve Secure Access token: {e.response.status_code} - {e.response.text}" + ) + # Clear potentially invalid token on failure + self._token = None + self._token_expiry = None + raise SecureAccessClientError("Failed to retrieve Secure Access token.") from e + except httpx.RequestError as e: + logger.error(f"An error occurred while requesting Secure Access token: {e}") + # Clear potentially invalid token on failure + self._token = None + self._token_expiry = None + raise SecureAccessClientError("Network error during token retrieval.") from e + + def _get_headers(self) -> Dict[str, str]: + """Returns common headers with the bearer token.""" + return { + "Authorization": f"Bearer {self._get_token()}", + "Content-Type": "application/json", + } + + def get_sgts(self) -> List[SecurityGroupTag]: + """ + Retrieve all Security Group Tags from Secure Access. + Handles pagination. + """ + logger.info("Retrieving Secure Access SGTs...") + sgt_list: List[SecurityGroupTag] = [] + url = f"{self.config.BASE_SA_IDENTITY_URL}/registrations/securityGroupTag" + headers = self._get_headers() + + limit = 250 # Max limit allowed by the API + offset = 0 + total_sgts = -1 + + try: + while total_sgts == -1 or offset < total_sgts: + params = {"limit": limit, "offset": offset} + logger.debug( + f"Fetching SGTs from SA with offset={offset}, limit={limit}" + ) + response = self.client.get(url, headers=headers, params=params) + response.raise_for_status() + data = response.json() + + if "data" in data and isinstance(data["data"], list): + for sa_sgt_data in data["data"]: + try: + sgt_list.append(SecurityGroupTag.from_sa_data(sa_sgt_data)) + except ValueError as ve: + logger.warning(f"Skipping malformed SA SGT data: {sa_sgt_data} - {ve}") + + total_sgts = data.get("total", len(sgt_list)) + offset += len(data.get("data", [])) + + if not data.get("data") and total_sgts > 0 and offset < total_sgts: + logger.warning( + "SA API returned no data but total count suggests more items. Breaking loop." + ) + break + elif total_sgts == 0: + break + + logger.info(f"Successfully retrieved {len(sgt_list)} Secure Access SGTs.") + return sgt_list + except httpx.HTTPStatusError as e: + logger.error( + f"Failed to retrieve Secure Access SGTs: {e.response.status_code} - {e.response.text}" + ) + raise SecureAccessClientError("Failed to retrieve Secure Access SGTs.") from e + except httpx.RequestError as e: + logger.error(f"An error occurred while requesting Secure Access SGTs: {e}") + raise SecureAccessClientError("Network error during SA SGT retrieval.") from e + + def put_sgts(self, sgts_to_sync: List[SecurityGroupTag]): + """ + Add or update Security Group Tags in Secure Access. + This function batches requests to adhere to the API's maxItems limit (250). + """ + if not sgts_to_sync: + logger.info("No SGTs to add/update to Secure Access.") + return + + logger.info( + f"Attempting to add/update {len(sgts_to_sync)} SGTs to Secure Access..." + ) + url = f"{self.config.BASE_SA_IDENTITY_URL}/registrations/securityGroupTag" + headers = self._get_headers() + + batch_size = 250 + batch_payload = [] + + try: + for i in range(0, len(sgts_to_sync), batch_size): + batch_sgts = sgts_to_sync[i : i + batch_size] + batch_payload = [sgt.to_sa_format() for sgt in batch_sgts] + + logger.debug( + f"Processing batch {i // batch_size + 1} with {len(batch_payload)} SGTs." + ) + response = self.client.put(url, headers=headers, json=batch_payload) + response.raise_for_status() + result = response.json() + if result.get("success"): + logger.info(f"Successfully processed batch of {len(batch_payload)} SGTs.") + else: + error_msg = result.get("error", "Unknown error") + logger.error( + f"Failed to process batch of {len(batch_payload)} SGTs. Response: {error_msg}" + ) + raise SecureAccessClientError(f"SA API reported error for batch: {error_msg}") + + logger.info("Finished adding/updating SGTs to Secure Access.") + except httpx.HTTPStatusError as e: + logger.error( + f"Failed to add/update Secure Access SGTs: {e.response.status_code} - {e.response.text}" + ) + logger.error(f"Request payload: {json.dumps(batch_payload, indent=2)}") + raise SecureAccessClientError("Failed to update Secure Access SGTs.") from e + except httpx.RequestError as e: + logger.error( + f"An error occurred while requesting to update Secure Access SGTs: {e}" + ) + raise SecureAccessClientError("Network error during SA SGT update.") from e \ No newline at end of file diff --git a/Cisco Secure Access/Samples/Identities/sgt-sync/sgt-sync/config.py b/Cisco Secure Access/Samples/Identities/sgt-sync/sgt-sync/config.py new file mode 100644 index 0000000..2ccbd12 --- /dev/null +++ b/Cisco Secure Access/Samples/Identities/sgt-sync/sgt-sync/config.py @@ -0,0 +1,32 @@ +# sgt_sync/config.py +import os +from dotenv import load_dotenv + +load_dotenv() + + +class Config: + """Manages application configuration from environment variables.""" + + ISE_SERVER: str = os.getenv("ISE_SERVER") + ISE_USER: str = os.getenv("ISE_USER") + ISE_PASS: str = os.getenv("ISE_PASS") + SA_KEY: str = os.getenv("SA_KEY") + SA_SECRET: str = os.getenv("SA_SECRET") + + BASE_SA_IDENTITY_URL: str = "https://api.sse.cisco.com/deployments/v2/identities" + BASE_SA_AUTH_URL: str = "https://api.sse.cisco.com/auth/v2/token" + BASE_ISE_ERS_URL: str = f"https://{ISE_SERVER}:9060/ers/config" + + # WARNING: This should be True in production environments! + VERIFY_SSL: bool = False + + @classmethod + def validate(cls): + """Validates that all required environment variables are set.""" + required_vars = ["ISE_SERVER", "ISE_USER", "ISE_PASS", "SA_KEY", "SA_SECRET"] + missing_vars = [var for var in required_vars if getattr(cls, var) is None] + if missing_vars: + raise ValueError( + f"Missing required environment variables: {', '.join(missing_vars)}. Please set them." + ) diff --git a/Cisco Secure Access/Samples/Identities/sgt-sync/sgt-sync/logging_config.py b/Cisco Secure Access/Samples/Identities/sgt-sync/sgt-sync/logging_config.py new file mode 100644 index 0000000..a44e028 --- /dev/null +++ b/Cisco Secure Access/Samples/Identities/sgt-sync/sgt-sync/logging_config.py @@ -0,0 +1,35 @@ +# sgt_sync/logging_config.py +import logging +from logging.config import dictConfig + +def setup_logging(): + """Configures logging for the application and returns the main logger.""" + dictConfig( + { + "version": 1, + "disable_existing_loggers": False, + "formatters": { + "standard": { + "format": "%(asctime)s - [%(name)s:%(lineno)d]: %(levelname)s: %(message)s", + "datefmt": "%Y-%m-%d %H:%M:%S", + } + }, + "handlers": { + "default": { + "level": "INFO", + "class": "logging.StreamHandler", + "formatter": "standard", + "stream": "ext://sys.stdout", + }, + }, + "loggers": { + "sgt_sync": { + "handlers": ["default"], + "level": "INFO", + "propagate": False, + }, + }, + "root": {"level": "INFO", "handlers": ["default"]}, + } + ) + return logging.getLogger("sgt_sync") \ No newline at end of file diff --git a/Cisco Secure Access/Samples/Identities/sgt-sync/sgt-sync/models/sgt.py b/Cisco Secure Access/Samples/Identities/sgt-sync/sgt-sync/models/sgt.py new file mode 100644 index 0000000..ace0f7b --- /dev/null +++ b/Cisco Secure Access/Samples/Identities/sgt-sync/sgt-sync/models/sgt.py @@ -0,0 +1,51 @@ +# sgt_sync/models/sgt.py +from dataclasses import dataclass + +@dataclass +class SecurityGroupTag: + """ + Represents a Security Group Tag in a normalized format. + This model is used internally for diffing and synchronization. + """ + key: str # Unique identifier (e.g., ISE ID, SA UUID) + label: str # Display name of the SGT + tag_id: int # Numeric SGT value + status: str # "active" or "inactive" + + def to_sa_format(self) -> dict: + """Converts the SGT to the format expected by Secure Access API.""" + return { + "key": self.key, + "label": self.label, + "status": self.status, + "tagId": self.tag_id, + } + + @staticmethod + def from_ise_data(ise_sgt_data: dict) -> "SecurityGroupTag": + """ + Transforms raw ISE SGT data into the normalized SecurityGroupTag model. + ISE 'id' is used as 'key' for comparison and identification. + """ + if not all(k in ise_sgt_data for k in ["id", "name", "value"]): + raise ValueError(f"Malformed ISE SGT data: {ise_sgt_data}") + return SecurityGroupTag( + key=ise_sgt_data["id"], + label=ise_sgt_data["name"], + tag_id=ise_sgt_data["value"], + status="active", + ) + + @staticmethod + def from_sa_data(sa_sgt_data: dict) -> "SecurityGroupTag": + """ + Transforms raw Secure Access SGT data into the normalized SecurityGroupTag model. + """ + if not all(k in sa_sgt_data for k in ["key", "label", "tagId", "status"]): + raise ValueError(f"Malformed Secure Access SGT data: {sa_sgt_data}") + return SecurityGroupTag( + key=sa_sgt_data["key"], + label=sa_sgt_data["label"], + tag_id=sa_sgt_data["tagId"], + status=sa_sgt_data["status"], + ) \ No newline at end of file diff --git a/Cisco Secure Access/Samples/Identities/sgt-sync/sgt-sync/synchronizer.py b/Cisco Secure Access/Samples/Identities/sgt-sync/sgt-sync/synchronizer.py new file mode 100644 index 0000000..0e88d49 --- /dev/null +++ b/Cisco Secure Access/Samples/Identities/sgt-sync/sgt-sync/synchronizer.py @@ -0,0 +1,141 @@ +# sgt_sync/synchronizer.py +import logging +from typing import List, Tuple, Dict + +from .clients.ise_client import IseClient +from .clients.secure_access_client import SecureAccessClient +from .models.sgt import SecurityGroupTag + +logger = logging.getLogger("sgt_sync.synchronizer") + + +class SgtSynchronizer: + """ + Orchestrates the synchronization of Security Group Tags from ISE (source of truth) + to Cisco Secure Access. + """ + + def __init__(self, ise_client: IseClient, sa_client: SecureAccessClient): + self.ise_client = ise_client + self.sa_client = sa_client + + def diff_sgts( + self, ise_sgts: List[SecurityGroupTag], sa_sgts: List[SecurityGroupTag] + ) -> Tuple[List[SecurityGroupTag], List[SecurityGroupTag]]: + """ + Public method to compare SGTs from ISE and Secure Access to determine + necessary synchronization actions without performing them. + + Returns: + tuple[list, list]: + 1. SGTs to be added or updated in Secure Access. + 2. SGTs to be marked 'inactive' in Secure Access (no longer exist in ISE). + """ + logger.info("Diffing SGTs between ISE and Secure Access...") + sgts_to_add_update: List[SecurityGroupTag] = [] + sgts_to_mark_inactive: List[SecurityGroupTag] = [] + + sa_sgts_map: Dict[str, SecurityGroupTag] = {sgt.key: sgt for sgt in sa_sgts} + ise_keys_set = {sgt.key for sgt in ise_sgts} + + # Identify SGTs to Add or Update in Secure Access + for ise_sgt in ise_sgts: + sa_sgt = sa_sgts_map.get(ise_sgt.key) + + if sa_sgt: + # SGT exists in Secure Access, check if it needs updating + if ( + ise_sgt.label != sa_sgt.label + or ise_sgt.tag_id != sa_sgt.tag_id + or ise_sgt.status != sa_sgt.status + ): + logger.info( + f"SGT '{ise_sgt.label}' (key: {ise_sgt.key}) in SA has incorrect values. Marking for update." + ) + logger.debug( + f"ISE: Label='{ise_sgt.label}', TagId={ise_sgt.tag_id}, Status='{ise_sgt.status}'" + ) + logger.debug( + f"SA : Label='{sa_sgt.label}', TagId={sa_sgt.tag_id}, Status='{sa_sgt.status}'" + ) + sgts_to_add_update.append(ise_sgt) + else: + logger.debug( + f"SGT '{ise_sgt.label}' (key: {ise_sgt.key}) is already up-to-date in Secure Access." + ) + else: + # SGT does not exist in Secure Access, mark for addition + logger.info( + f"SGT '{ise_sgt.label}' (key: {ise_sgt.key}) not found in Secure Access. Marking for addition." + ) + sgts_to_add_update.append(ise_sgt) + + # Identify SGTs to Mark Inactive (exist in SA but not in ISE) + for sa_key, sa_sgt in sa_sgts_map.items(): + if sa_key not in ise_keys_set: + # Only mark for inactive if its current status is 'active' to avoid redundant updates + if sa_sgt.status == "active": + logger.info( + f"SGT '{sa_sgt.label}' (key: {sa_key}) exists in Secure Access but not in ISE. Marking for 'inactive'." + ) + + # Create a new SGT object with 'inactive' status for deletion + sgts_to_mark_inactive.append( + SecurityGroupTag( + key=sa_sgt.key, + label=sa_sgt.label, + tag_id=sa_sgt.tag_id, + status="inactive", + ) + ) + else: + logger.debug( + f"SGT '{sa_sgt.label}' (key: {sa_key}) exists in SA but not ISE, and is already 'inactive'. No action needed." + ) + + logger.info( + f"Found {len(sgts_to_add_update)} SGTs that need to be added or updated in Secure Access." + ) + logger.info( + f"Found {len(sgts_to_mark_inactive)} SGTs that need to be marked 'inactive' in Secure Access." + ) + + return sgts_to_add_update, sgts_to_mark_inactive + + def sync_sgts(self): + """ + Executes the full SGT synchronization process. + """ + logger.info("Starting SGT synchronization process.") + + ise_sgts = self.ise_client.get_sgts() + logger.info(f"Retrieved {len(ise_sgts)} SGTs from ISE.") + + sa_sgts = self.sa_client.get_sgts() + logger.info(f"Retrieved {len(sa_sgts)} SGTs from Secure Access.") + + sgts_to_add_update, sgts_to_mark_inactive = self.diff_sgts(ise_sgts, sa_sgts) + + if sgts_to_add_update: + logger.info( + f"Initiating synchronization for {len(sgts_to_add_update)} SGTs (add/update) to Secure Access..." + ) + self.sa_client.put_sgts(sgts_to_add_update) + logger.info( + "SGT add/update synchronization to Secure Access completed successfully." + ) + else: + logger.info("No SGTs needed to be added or updated in Secure Access.") + + if sgts_to_mark_inactive: + logger.info( + f"Initiating marking inactive for {len(sgts_to_mark_inactive)} SGTs in Secure Access..." + ) + self.sa_client.put_sgts(sgts_to_mark_inactive) + logger.info( + "SGT marking inactive sync to Secure Access completed successfully." + ) + else: + logger.info("No SGTs needed to be marked 'inactive' in Secure Access.") + + logger.info("SGT synchronization process completed.") diff --git a/Cisco Secure Access/Samples/Identities/sgt-sync/uv.lock b/Cisco Secure Access/Samples/Identities/sgt-sync/uv.lock new file mode 100644 index 0000000..9710ede --- /dev/null +++ b/Cisco Secure Access/Samples/Identities/sgt-sync/uv.lock @@ -0,0 +1,194 @@ +version = 1 +requires-python = ">=3.13" + +[[package]] +name = "anyio" +version = "4.10.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, + { name = "sniffio" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f1/b4/636b3b65173d3ce9a38ef5f0522789614e590dab6a8d505340a4efe4c567/anyio-4.10.0.tar.gz", hash = "sha256:3f3fae35c96039744587aa5b8371e7e8e603c0702999535961dd336026973ba6", size = 213252 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6f/12/e5e0282d673bb9746bacfb6e2dba8719989d3660cdb2ea79aee9a9651afb/anyio-4.10.0-py3-none-any.whl", hash = "sha256:60e474ac86736bbfd6f210f7a61218939c318f43f9972497381f1c5e930ed3d1", size = 107213 }, +] + +[[package]] +name = "argparse" +version = "1.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/18/dd/e617cfc3f6210ae183374cd9f6a26b20514bbb5a792af97949c5aacddf0f/argparse-1.4.0.tar.gz", hash = "sha256:62b089a55be1d8949cd2bc7e0df0bddb9e028faefc8c32038cc84862aefdd6e4", size = 70508 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f2/94/3af39d34be01a24a6e65433d19e107099374224905f1e0cc6bbe1fd22a2f/argparse-1.4.0-py2.py3-none-any.whl", hash = "sha256:c31647edb69fd3d465a847ea3157d37bed1f95f19760b11a47aa91c04b666314", size = 23000 }, +] + +[[package]] +name = "certifi" +version = "2025.8.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/dc/67/960ebe6bf230a96cda2e0abcf73af550ec4f090005363542f0765df162e0/certifi-2025.8.3.tar.gz", hash = "sha256:e564105f78ded564e3ae7c923924435e1daa7463faeab5bb932bc53ffae63407", size = 162386 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e5/48/1549795ba7742c948d2ad169c1c8cdbae65bc450d6cd753d124b17c8cd32/certifi-2025.8.3-py3-none-any.whl", hash = "sha256:f6c12493cfb1b06ba2ff328595af9350c65d6644968e5d3a2ffd78699af217a5", size = 161216 }, +] + +[[package]] +name = "charset-normalizer" +version = "3.4.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/83/2d/5fd176ceb9b2fc619e63405525573493ca23441330fcdaee6bef9460e924/charset_normalizer-3.4.3.tar.gz", hash = "sha256:6fce4b8500244f6fcb71465d4a4930d132ba9ab8e71a7859e6a5d59851068d14", size = 122371 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/65/ca/2135ac97709b400c7654b4b764daf5c5567c2da45a30cdd20f9eefe2d658/charset_normalizer-3.4.3-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:14c2a87c65b351109f6abfc424cab3927b3bdece6f706e4d12faaf3d52ee5efe", size = 205326 }, + { url = "https://files.pythonhosted.org/packages/71/11/98a04c3c97dd34e49c7d247083af03645ca3730809a5509443f3c37f7c99/charset_normalizer-3.4.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:41d1fc408ff5fdfb910200ec0e74abc40387bccb3252f3f27c0676731df2b2c8", size = 146008 }, + { url = "https://files.pythonhosted.org/packages/60/f5/4659a4cb3c4ec146bec80c32d8bb16033752574c20b1252ee842a95d1a1e/charset_normalizer-3.4.3-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1bb60174149316da1c35fa5233681f7c0f9f514509b8e399ab70fea5f17e45c9", size = 159196 }, + { url = "https://files.pythonhosted.org/packages/86/9e/f552f7a00611f168b9a5865a1414179b2c6de8235a4fa40189f6f79a1753/charset_normalizer-3.4.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:30d006f98569de3459c2fc1f2acde170b7b2bd265dc1943e87e1a4efe1b67c31", size = 156819 }, + { url = "https://files.pythonhosted.org/packages/7e/95/42aa2156235cbc8fa61208aded06ef46111c4d3f0de233107b3f38631803/charset_normalizer-3.4.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:416175faf02e4b0810f1f38bcb54682878a4af94059a1cd63b8747244420801f", size = 151350 }, + { url = "https://files.pythonhosted.org/packages/c2/a9/3865b02c56f300a6f94fc631ef54f0a8a29da74fb45a773dfd3dcd380af7/charset_normalizer-3.4.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6aab0f181c486f973bc7262a97f5aca3ee7e1437011ef0c2ec04b5a11d16c927", size = 148644 }, + { url = "https://files.pythonhosted.org/packages/77/d9/cbcf1a2a5c7d7856f11e7ac2d782aec12bdfea60d104e60e0aa1c97849dc/charset_normalizer-3.4.3-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:fdabf8315679312cfa71302f9bd509ded4f2f263fb5b765cf1433b39106c3cc9", size = 160468 }, + { url = "https://files.pythonhosted.org/packages/f6/42/6f45efee8697b89fda4d50580f292b8f7f9306cb2971d4b53f8914e4d890/charset_normalizer-3.4.3-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:bd28b817ea8c70215401f657edef3a8aa83c29d447fb0b622c35403780ba11d5", size = 158187 }, + { url = "https://files.pythonhosted.org/packages/70/99/f1c3bdcfaa9c45b3ce96f70b14f070411366fa19549c1d4832c935d8e2c3/charset_normalizer-3.4.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:18343b2d246dc6761a249ba1fb13f9ee9a2bcd95decc767319506056ea4ad4dc", size = 152699 }, + { url = "https://files.pythonhosted.org/packages/a3/ad/b0081f2f99a4b194bcbb1934ef3b12aa4d9702ced80a37026b7607c72e58/charset_normalizer-3.4.3-cp313-cp313-win32.whl", hash = "sha256:6fb70de56f1859a3f71261cbe41005f56a7842cc348d3aeb26237560bfa5e0ce", size = 99580 }, + { url = "https://files.pythonhosted.org/packages/9a/8f/ae790790c7b64f925e5c953b924aaa42a243fb778fed9e41f147b2a5715a/charset_normalizer-3.4.3-cp313-cp313-win_amd64.whl", hash = "sha256:cf1ebb7d78e1ad8ec2a8c4732c7be2e736f6e5123a4146c5b89c9d1f585f8cef", size = 107366 }, + { url = "https://files.pythonhosted.org/packages/8e/91/b5a06ad970ddc7a0e513112d40113e834638f4ca1120eb727a249fb2715e/charset_normalizer-3.4.3-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:3cd35b7e8aedeb9e34c41385fda4f73ba609e561faedfae0a9e75e44ac558a15", size = 204342 }, + { url = "https://files.pythonhosted.org/packages/ce/ec/1edc30a377f0a02689342f214455c3f6c2fbedd896a1d2f856c002fc3062/charset_normalizer-3.4.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b89bc04de1d83006373429975f8ef9e7932534b8cc9ca582e4db7d20d91816db", size = 145995 }, + { url = "https://files.pythonhosted.org/packages/17/e5/5e67ab85e6d22b04641acb5399c8684f4d37caf7558a53859f0283a650e9/charset_normalizer-3.4.3-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2001a39612b241dae17b4687898843f254f8748b796a2e16f1051a17078d991d", size = 158640 }, + { url = "https://files.pythonhosted.org/packages/f1/e5/38421987f6c697ee3722981289d554957c4be652f963d71c5e46a262e135/charset_normalizer-3.4.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8dcfc373f888e4fb39a7bc57e93e3b845e7f462dacc008d9749568b1c4ece096", size = 156636 }, + { url = "https://files.pythonhosted.org/packages/a0/e4/5a075de8daa3ec0745a9a3b54467e0c2967daaaf2cec04c845f73493e9a1/charset_normalizer-3.4.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:18b97b8404387b96cdbd30ad660f6407799126d26a39ca65729162fd810a99aa", size = 150939 }, + { url = "https://files.pythonhosted.org/packages/02/f7/3611b32318b30974131db62b4043f335861d4d9b49adc6d57c1149cc49d4/charset_normalizer-3.4.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ccf600859c183d70eb47e05a44cd80a4ce77394d1ac0f79dbd2dd90a69a3a049", size = 148580 }, + { url = "https://files.pythonhosted.org/packages/7e/61/19b36f4bd67f2793ab6a99b979b4e4f3d8fc754cbdffb805335df4337126/charset_normalizer-3.4.3-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:53cd68b185d98dde4ad8990e56a58dea83a4162161b1ea9272e5c9182ce415e0", size = 159870 }, + { url = "https://files.pythonhosted.org/packages/06/57/84722eefdd338c04cf3030ada66889298eaedf3e7a30a624201e0cbe424a/charset_normalizer-3.4.3-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:30a96e1e1f865f78b030d65241c1ee850cdf422d869e9028e2fc1d5e4db73b92", size = 157797 }, + { url = "https://files.pythonhosted.org/packages/72/2a/aff5dd112b2f14bcc3462c312dce5445806bfc8ab3a7328555da95330e4b/charset_normalizer-3.4.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d716a916938e03231e86e43782ca7878fb602a125a91e7acb8b5112e2e96ac16", size = 152224 }, + { url = "https://files.pythonhosted.org/packages/b7/8c/9839225320046ed279c6e839d51f028342eb77c91c89b8ef2549f951f3ec/charset_normalizer-3.4.3-cp314-cp314-win32.whl", hash = "sha256:c6dbd0ccdda3a2ba7c2ecd9d77b37f3b5831687d8dc1b6ca5f56a4880cc7b7ce", size = 100086 }, + { url = "https://files.pythonhosted.org/packages/ee/7a/36fbcf646e41f710ce0a563c1c9a343c6edf9be80786edeb15b6f62e17db/charset_normalizer-3.4.3-cp314-cp314-win_amd64.whl", hash = "sha256:73dc19b562516fc9bcf6e5d6e596df0b4eb98d87e4f79f3ae71840e6ed21361c", size = 107400 }, + { url = "https://files.pythonhosted.org/packages/8a/1f/f041989e93b001bc4e44bb1669ccdcf54d3f00e628229a85b08d330615c5/charset_normalizer-3.4.3-py3-none-any.whl", hash = "sha256:ce571ab16d890d23b5c278547ba694193a45011ff86a9162a71307ed9f86759a", size = 53175 }, +] + +[[package]] +name = "dotenv" +version = "0.9.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "python-dotenv" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/b2/b7/545d2c10c1fc15e48653c91efde329a790f2eecfbbf2bd16003b5db2bab0/dotenv-0.9.9-py2.py3-none-any.whl", hash = "sha256:29cf74a087b31dafdb5a446b6d7e11cbce8ed2741540e2339c69fbef92c94ce9", size = 1892 }, +] + +[[package]] +name = "h11" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515 }, +] + +[[package]] +name = "httpcore" +version = "1.0.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784 }, +] + +[[package]] +name = "httpx" +version = "0.28.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517 }, +] + +[[package]] +name = "idna" +version = "3.10" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f1/70/7703c29685631f5a7590aa73f1f1d3fa9a380e654b86af429e0934a32f7d/idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9", size = 190490 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3", size = 70442 }, +] + +[[package]] +name = "python-dotenv" +version = "1.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/b0/4bc07ccd3572a2f9df7e6782f52b0c6c90dcbb803ac4a167702d7d0dfe1e/python_dotenv-1.1.1.tar.gz", hash = "sha256:a8a6399716257f45be6a007360200409fce5cda2661e3dec71d23dc15f6189ab", size = 41978 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5f/ed/539768cf28c661b5b068d66d96a2f155c4971a5d55684a514c1a0e0dec2f/python_dotenv-1.1.1-py3-none-any.whl", hash = "sha256:31f23644fe2602f88ff55e1f5c79ba497e01224ee7737937930c448e4d0e24dc", size = 20556 }, +] + +[[package]] +name = "requests" +version = "2.32.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c9/74/b3ff8e6c8446842c3f5c837e9c3dfcfe2018ea6ecef224c710c85ef728f4/requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf", size = 134517 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738 }, +] + +[[package]] +name = "requests-auth" +version = "8.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2b/c7/3a1119e11477e789bf4a75cadf9c09cf3b6fd7df3c38011a71583346762b/requests_auth-8.0.0.tar.gz", hash = "sha256:ca2f2126d8a41e1d1615faa8cf8d5d62ea01d705f9ee99f470b9a44abd5dee82", size = 80146 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6d/c6/a586233b044203b9faec662ed147421730fcbd16040c72753445abd8dced/requests_auth-8.0.0-py3-none-any.whl", hash = "sha256:7faf0c58cadb61d2398fed9ea412a38641d70a856b1db25db281f9057194f1ca", size = 39432 }, +] + +[[package]] +name = "sgt-sync" +version = "0.1.0" +source = { virtual = "." } +dependencies = [ + { name = "argparse" }, + { name = "dotenv" }, + { name = "httpx" }, + { name = "requests-auth" }, +] + +[package.metadata] +requires-dist = [ + { name = "argparse", specifier = ">=1.4.0" }, + { name = "dotenv", specifier = ">=0.9.9" }, + { name = "httpx", specifier = ">=0.28.1" }, + { name = "requests-auth", specifier = ">=8.0.0" }, +] + +[[package]] +name = "sniffio" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235 }, +] + +[[package]] +name = "urllib3" +version = "2.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/15/22/9ee70a2574a4f4599c47dd506532914ce044817c7752a79b6a51286319bc/urllib3-2.5.0.tar.gz", hash = "sha256:3fc47733c7e419d4bc3f6b3dc2b4f890bb743906a30d56ba4a5bfa4bbff92760", size = 393185 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a7/c2/fe1e52489ae3122415c51f387e221dd0773709bad6c6cdaa599e8a2c5185/urllib3-2.5.0-py3-none-any.whl", hash = "sha256:e6b01673c0fa6a13e374b50871808eb3bf7046c4b125b216f6bf1cc604cff0dc", size = 129795 }, +]