COOCApiModule

COOC API Module class.

python · ApiModules

Source

from enum import Enum
import demistomock as demisto  # noqa: F401
from CommonServerPython import *  # noqa: F401
from collections.abc import Callable
from typing import Any
import requests


class CloudTypes(Enum):
    AWS = "AWS"
    AZURE = "AZURE"
    GCP = "GCP"
    OCI = "OCI"


# Provider-specific account identifier names
PROVIDER_ACCOUNT_NAMES = {
    CloudTypes.GCP.value: "Project ID",
    CloudTypes.AWS.value: "AWS Account ID",
    CloudTypes.AZURE.value: "Subscription ID",
    CloudTypes.OCI.value: "Oracle Cloud Account ID",
}

# Platform API paths
GET_CTS_ACCOUNTS_TOKEN = "/cts/accounts/token"
GET_ONBOARDING_ACCOUNTS = "/onboarding/accounts"
GET_ONBOARDING_CONNECTORS = "/onboarding/connectors"


class HealthStatus(str):
    ERROR = "ERROR"
    WARNING = "WARNING"
    OK = "ok"


class ErrorType(str):
    CONNECTIVITY_ERROR = "Connectivity Error"
    PERMISSION_ERROR = "Permission Error"
    INTERNAL_ERROR = "Internal Error"


class HealthCheckError:
    def __init__(self, account_id: str, connector_id: str, message: str, error_type: str):
        self.account_id = account_id
        self.connector_id = connector_id
        self.message = message
        self.error_type = error_type
        # Determine classification based on error type
        self.classification = HealthStatus.WARNING if self.error_type == ErrorType.PERMISSION_ERROR else HealthStatus.ERROR

    def to_dict(self) -> dict:
        return {
            "account_id": self.account_id,
            "connector_id": self.connector_id,
            "message": self.message,
            "error": self.error_type,
            "classification": self.classification,
        }


class HealthCheck:
    """Health check results container for cloud connector."""

    def __init__(self, connector_id: str):
        self.errors: list[HealthCheckError] = []
        self.connector_id = connector_id

    def error(self, error: HealthCheckError | list[HealthCheckError]) -> None:
        """Adds a health check error or list of errors to the results.

        Args:
            error (HealthCheckError | list[HealthCheckError]): The error(s) to add to the results.
        """
        if isinstance(error, list):
            self.errors.extend(error)
        else:
            self.errors.append(error)

    def summarize(self) -> CommandResults | str:
        """Summarizes the health check results by calculating severity based on error classifications.

        Returns:
            CommandResults | str:
                - If errors exist: CommandResults object with severity level and error details, otherwise "ok".
        """

        def _calculate_severity() -> int:
            """
            Calculates the severity level based on error classifications.
            """
            return EntryType.ERROR if HealthStatus.ERROR in [error.classification for error in self.errors] else EntryType.WARNING

        if not self.errors:
            return HealthStatus.OK

        error_list = [error.to_dict() for error in self.errors]
        return CommandResults(entry_type=_calculate_severity(), content_format=EntryFormat.JSON, raw_response=error_list)


def get_connector_id() -> str | None:
    """
    Retrieves the connector ID from the calling context.

    This function extracts the connector ID from the CloudIntegrationInfo in the calling context.

    Returns:
        str | None: The connector ID if available in the context, otherwise None.
    """
    cloud_info_context = demisto.callingContext.get("context", {}).get("CloudIntegrationInfo", {})
    demisto.debug(f"[COOC API] Cloud credentials request context: {cloud_info_context}")

    if connector_id := cloud_info_context.get("connectorID"):
        demisto.debug(f"[COOC API] Retrieved connector ID from context: {connector_id}")
    else:
        demisto.debug("[COOC API] No connector ID found in context")

    return connector_id


def get_cloud_credentials(cloud_type: str, account_id: str, scopes: list = None) -> dict:
    """Retrieves valid credentials for the specified cloud provider from CTS.

    Args:
        cloud_type (str): Cloud provider type ("GCP", "AWS", "AZURE", "OCI").
        account_id (str): Cloud account identifier - GCP: Project ID, AWS: Account ID,
                         AZURE: Subscription ID
        scopes (list, optional): Authorization scopes. Defaults to None.

    Returns:
        dict: Credentials dictionary for the specified cloud provider. The structure varies by cloud type:
            - For all providers:
                - 'expiration_time' (int): Expiration time in epoch time (milliseconds)
            - For GCP:
                - 'access_token' (str): Bearer token
            - For AWS:
                - 'access_token' (str): SecretAccessKey
                - 'session_token' (str): SessionToken
                - 'key' (str): AccessKeyId
            - For AZURE:
                - 'access_token' (str): JWT

    Raises:
        DemistoException: If token retrieval fails or response parsing fails.
        ValueError: If account_id is not provided.
    """
    if not account_id:
        name = PROVIDER_ACCOUNT_NAMES.get(cloud_type, "account identifier")
        raise ValueError(f"Missing {name} for {cloud_type}")

    cloud_info_context = demisto.callingContext.get("context", {}).get("CloudIntegrationInfo", {})

    request_data = {
        "connector_id": cloud_info_context.get("connectorID"),
        "account_id": account_id,
        "outpost_id": cloud_info_context.get("outpostID"),
        "cloud_type": cloud_type,
    }

    if scopes:
        request_data["scopes"] = scopes

    demisto.debug(f"[COOC API] Request data for credentials retrieval: {request_data}")
    response = None

    try:
        response = demisto._platformAPICall(path=GET_CTS_ACCOUNTS_TOKEN, method="POST", data={"request_data": request_data})
        raw_data = response.get("data")

        if not raw_data:
            raise ValueError(f"No 'data' field in CTS response: {response}")

        if isinstance(raw_data, str):
            res_json = json.loads(raw_data)
        elif isinstance(raw_data, dict):
            res_json = raw_data
        else:
            raise ValueError(f"Unexpected type for response['data']: {type(raw_data)}")

        credentials = res_json.get("data")
        if not credentials:
            raise KeyError("Did not receive any credentials from CTS.")

        expiration_time = credentials.get("expiration_time")
        demisto.debug(f"[COOC API] {account_id}: Received credentials. Expiration time: {expiration_time}")
        return credentials

    except Exception as e:
        demisto.debug(f"[COOC API] {account_id}: Error while retrieving credentials: {str(e)}. Response: {response}")
        raise DemistoException(f"Failed to get credentials from CTS: {str(e)}. Response: {response}")


def get_accounts_by_connector_id(connector_id: str, max_results: int | None = 1) -> list:
    """
    Retrieves the accounts associated with a specific connector with pagination support.
    Args:
        connector_id (str): The ID of the connector to fetch accounts for.
        max_results (int, optional): Maximum number of results to return. Defaults to None (all results).
    Returns:
        list: List of accounts (only of type ACCOUNT) associated with the specified connector.
    """
    all_accounts = []
    next_token = ""
    try:
        while True:
            params = {"entity_type": "connector", "entity_id": connector_id}
            if next_token:
                params["next_token"] = next_token

            result = demisto._platformAPICall(GET_ONBOARDING_ACCOUNTS, "GET", params)
            res_json = json.loads(result["data"])
            accounts = res_json.get("values", [])
            all_accounts.extend([a for a in accounts if a.get("account_type") == "ACCOUNT" and a.get("account_id")])
            next_token = res_json.get("next_token", "")
            if not next_token or (max_results and len(all_accounts) >= max_results):
                break
    except Exception as e:
        raise DemistoException(f"Failed to fetch accounts for connector: {str(e)}")

    if max_results:
        return all_accounts[:max_results]
    return all_accounts


def _check_account(
    account_id: str, connector_id: str, shared_creds: dict, health_check_func: Callable[[dict, str, str], HealthCheckError]
) -> HealthCheckError | None:
    """Helper function to check a single account.

    Args:
        account_id (str): The Account ID.
        connector_id (str): The connector ID.
        shared_creds (dict): Pre-fetched credentials to reuse across all accounts.
        health_check_func (callable): Function that implements the health check.

    Returns:
        HealthCheckError | None: Result of the health check, or None if account has no ID.
    """

    try:
        return health_check_func(shared_creds, account_id, connector_id)
    except Exception as e:
        demisto.error(f"[COOC API] Error checking account {account_id}: {str(e)}")
        return HealthCheckError(
            account_id=account_id,
            connector_id=connector_id,
            message=f"Failed to check account: {str(e)}",
            error_type=ErrorType.INTERNAL_ERROR,
        )


def run_health_check_for_accounts(
    connector_id: str, cloud_type: str, health_check_func: Callable[[dict, str, str], Any]
) -> str | CommandResults:
    """Runs a health check function for each account associated with a connector sequentially.

    Args:
        connector_id (str): The ID of the connector to fetch accounts for.
        cloud_type (str): The cloud provider type (AWS, GCP, AZURE, OCI).
        health_check_func (callable): Function that implements the health check.
                                        Should accept shared_creds, account_id and connector_id parameters
                                        and return a HealthCheckError or None.

    Returns:
        str | CommandResults: Either "ok" string or CommandResults with appropriate EntryType

    Raises:
        DemistoException: If the account retrieval fails.
    """
    health_check_result = HealthCheck(connector_id)
    try:
        accounts = get_accounts_by_connector_id(connector_id)
        if not accounts:
            demisto.debug(f"[COOC API] No accounts found for connector ID: {connector_id}")
            return HealthStatus.OK

        account_id = accounts[0]["account_id"]
        shared_creds = get_cloud_credentials(cloud_type, account_id)
        demisto.debug(f"[COOC API] Retrieved shared {cloud_type} credentials for all accounts")

    except Exception as e:
        error_msg = f"Failed to retrieve {cloud_type} credentials for connector {connector_id}: {str(e)}"
        demisto.error(error_msg)
        health_check_result.error(
            HealthCheckError(
                account_id="",  # No specific account since this is a connector-level error
                connector_id=connector_id,
                message=error_msg,
                error_type=ErrorType.CONNECTIVITY_ERROR,
            )
        )
        return health_check_result.summarize()

    result = _check_account(account_id, connector_id, shared_creds, health_check_func)
    if result is not None:
        health_check_result.error(result)

    demisto.debug(f"[COOC API] Completed processing {account_id}")
    return health_check_result.summarize()


def get_proxydome_token() -> str:
    """
    Retrieves a Proxydome identity token from the GCP metadata server.

    This function makes a request to the GCP metadata server to obtain an identity token
    that can be used for authentication with Proxydome services. It bypasses any configured
    proxies for this request.

    Returns:
        str: The identity token as a string.

    Raises:
        requests.RequestException: If the request to the metadata server fails.
    """
    url = "http://metadata/computeMetadata/v1/instance/service-accounts/default/identity"
    params = {"audience": os.getenv("CORTEX_AUDIENCE")}
    headers = {"Metadata-Flavor": "Google"}
    proxies = {"http": "", "https": ""}

    response = requests.get(url, headers=headers, params=params, proxies=proxies)
    return response.text


def return_multiple_permissions_error(error_entries: list[Dict]) -> None:
    """
    Handles permission errors responses and exits the script execution.

    This function logs permission errors, formats them as Demisto error entries,
    and terminates script execution. It's used when cloud operations fail due to
    insufficient permissions or authentication issues.

    Args:
        error_entries (list): List of dictionaries containing error details with the following structure:
            - account_id (str): The cloud account identifier where the error occurred
            - message (str): The permission error message (including the name of the permission)
            - name (str): The RAW name of the permission itself that is missing, for example containers.list"

    Returns:
        None: This function does not return as it calls sys.exit(0)
    """
    # Input validation
    entries = []
    for entry in error_entries:
        error_entry = create_permissions_error_entry(entry.get("account_id"), entry.get("message"), entry.get("name"))
        entries.append(error_entry)
        # Log the permission error for security audit purposes
        demisto.debug(f"[COOC API] Permission error detected for account {error_entry.get('account_id')}: {error_entry}")

    # Return formatted error response
    demisto.results(
        {
            "Type": entryTypes["error"],
            "ContentsFormat": formats["json"],
            "Contents": entries,
            "EntryContext": None,
        }
    )
    # Exit the script execution
    sys.exit(0)


def create_permissions_error_entry(account_id: Optional[str], message: Optional[str], name: Optional[str]) -> dict:
    """
    Creates a standardized error entry dictionary for permission-related errors.

    This function constructs a formatted error entry containing permission error details
    that can be used for logging and error handling in cloud operations. It validates
    input parameters and creates a consistent error structure.

    Args:
        account_id (Optional[str]): The cloud account identifier where the error occurred
        message (Optional[str]): The permission error message (including the name of the permission)
        name (Optional[str]): The RAW name of the permission itself that is missing, for example containers.list

    Returns:
        dict: A dictionary containing structured error information with keys:
            - account_id: The provided account identifier
            - message: The error message
            - name: The permission name
            - classification: Set to "WARNING"
            - error: Set to "Permission Error"
    """
    # Input validation
    error_entry = {"account_id": account_id, "message": message, "name": name}
    for var_name, var_value in error_entry.items():
        if not var_value or (isinstance(var_value, str) and not var_value.strip()):
            error_entry.update({var_name: "N/A"})
            demisto.info(f"[COOC API] Invalid entry was given to the permissions entry {var_name=}:{var_value=}.")

    error_entry.update({"classification": "WARNING", "error": "Permission Error"})
    # Log the permission error for security audit purposes
    demisto.debug(f"[COOC API] Permission error detected for account {error_entry.get('account_id')}: {error_entry}")

    # Return formatted error response
    return error_entry


def is_gov_account(connector_id: str, account_id: str = "") -> bool:
    """
    Return whether the account connected to the connector_id is a gov account or not.

    Args:
        connector_id (str): The connector id of the cloud provider.
        account_id (str): The relevant account id

    Returns:
        A boolean representing whether the account is a gov account or not.
    """
    accounts_info = get_accounts_by_connector_id(connector_id, None)  # return all accounts with max_results = None

    relevant_account = {}
    if account_id:
        demisto.debug(f"[COOC API]The found {account_id=}")
        for account in accounts_info:
            if account.get("account_id") == account_id:
                relevant_account = account
                demisto.debug("[COOC API] found the account")
                break
    elif accounts_info:
        demisto.debug(f"[COOC API] {account_id=}. Getting the first account for the health check from the existing accounts.")
        relevant_account = accounts_info[0]
    else:
        demisto.debug(f"[COOC API] There are no {account_id=} or {accounts_info=} for the {connector_id=}.")
        return False

    if account_cloud_partition := relevant_account.get("cloud_partition", ""):
        demisto.debug(f"[COOC API] The found {account_cloud_partition=}")
        return account_cloud_partition.upper() == "GOV"
    else:
        demisto.debug(f"[COOC API] The information found for account_id: {account_id}, {relevant_account=}.")
        demisto.debug(
            f"[COOC API] The account {account_id} cloud partition information is {relevant_account.get('cloud_partition')=}"
        )
        return False


def get_timeout(timeout: int | str | None) -> tuple[int, int]:
    """
    Parse the ``timeout`` integration parameter into ``(read_timeout, connect_timeout)``.

    Accepts either ``"<read>"`` (connect defaults to 10 s) or ``"<read>,<connect>"``.
    Mirrors ``AWSClient.get_timeout`` from ``AWSApiModule`` for behavioural parity with
    the legacy AWS-* packs. Intended for use by COOC integrations that support the
    XSOAR / XSIAM marketplace path.

    Args:
        timeout (int | str | None): The raw value from ``params.get("timeout")``. May be ``None``, an
            int, or a string.

    Returns:
        tuple[int, int]: ``(read_timeout, connect_timeout)`` in seconds.

    Raises:
        DemistoException: If the value is malformed.
    """
    if timeout is None or timeout == "":
        timeout = "60,10"
    try:
        if isinstance(timeout, int):
            if timeout < 0:
                raise DemistoException("Timeout values must not be negative.")
            return timeout, 10
        timeout_vals = str(timeout).split(",")
        if len(timeout_vals) > 2:
            raise DemistoException(
                f"Too many timeout values: expected 1 or 2, got {len(timeout_vals)}. "
                "You can specify just the read timeout (for example 60) or also the connect "
                "timeout followed after a comma (for example 60,10)."
            )
        read_timeout = int(timeout_vals[0])
        connect_timeout = 10 if len(timeout_vals) == 1 else int(timeout_vals[1])
        if read_timeout < 0 or connect_timeout < 0:
            raise DemistoException("Timeout values must not be negative.")
        return read_timeout, connect_timeout
    except ValueError:
        raise DemistoException(
            "You can specify just the read timeout (for example 60) or also the connect "
            "timeout followed after a comma (for example 60,10). If a connect timeout is "
            "not specified, a default of 10 seconds will be used."
        )

README

To use the COOC API logic, run the following command to import the COOCApiModule.

def main():
    ...


from COOCApiModule import *

if __name__ in ["builtins", "__main__"]:
    main()

Then, the functionality of COOCApiModule will be available for usage. For examples, see the AWS or AZURE integrations.