GSuiteApiModule

Common G Suite code that will be appended to each Google/GSuite integration when it is deployed.

python · ApiModules

Source

from CommonServerPython import *

""" IMPORTS """

import urllib.parse
from contextlib import contextmanager
from typing import Any

import httplib2
from google.auth import exceptions
from google.oauth2 import credentials as oauth2_credentials
from google.oauth2 import service_account
from google_auth_httplib2 import AuthorizedHttp

""" CONSTANTS """

COMMON_MESSAGES: dict[str, str] = {
    "TIMEOUT_ERROR": "Connection Timeout Error - potential reasons might be that the Server URL parameter"
    " is incorrect or that the Server is not accessible from your host. Reason: {}",
    "HTTP_ERROR": "HTTP Connection error occurred. Status: {}. Reason: {}",
    "TRANSPORT_ERROR": "Transport error occurred. Reason: {}",
    "AUTHENTICATION_ERROR": "Unauthenticated. Check the configured Service Account JSON. Reason: {}",
    "BAD_REQUEST_ERROR": "An error occurred while fetching/submitting the data. Reason: {}",
    "TOO_MANY_REQUESTS_ERROR": "Too many requests please try after sometime. Reason: {}",
    "INTERNAL_SERVER_ERROR": "The server encountered an internal error. Reason: {}",
    "AUTHORIZATION_ERROR": "Request has insufficient privileges. Reason: {}",
    "JSON_PARSE_ERROR": "Unable to parse JSON string. Please verify the JSON is valid.",
    "NOT_FOUND_ERROR": "Not found. Reason: {}",
    "UNKNOWN_ERROR": "An error occurred. Status: {}. Reason: {}",
    "PROXY_ERROR": "Proxy Error - if the 'Use system proxy' checkbox in the integration configuration is"
    " selected, try clearing the checkbox.",
    "REFRESH_ERROR": "Failed to generate/refresh token. Subject email or service account credentials are invalid. Reason: {}",
    "BOOLEAN_ERROR": "The argument {} must be either true or false.",
    "INTEGER_ERROR": "The argument {} must be a positive integer.",
    "UNEXPECTED_ERROR": "An unexpected error occurred.",
}


class GSuiteClient:
    """
    Client to use in integration with powerful http_request.
    """

    @staticmethod
    def get_ucp_access_token(subject: str | None = None) -> tuple[str, str]:
        """Fetch the OAuth2 access token for the active UCP connection profile.

        :param subject: The user to impersonate. When provided, it is sent to the
            UCP service as ``{"extra": {"subject": subject}}`` so the issued token
            impersonates that user (Google Workspace domain-wide delegation).

        :return: Tuple of ``(method_unique_id, access_token)``.
        :rtype: ``tuple``
        :raises UcpException: If no access token is present.
        """
        demisto.debug("calling access token from ucp service")
        method_id = get_ucp_method_unique_id(resolve_ucp_capability())

        body = {"extra": {"subject": subject}} if subject else None
        credentials = get_ucp_credentials(method_id, body=body)

        cred_type = credentials.get("type")
        token_data = credentials.get(cred_type, credentials) if cred_type else credentials
        access_token = token_data.get("access_token") if isinstance(token_data, dict) else None
        if not access_token:
            demisto.error("[UCP][GSuiteApiModule.py] access token is empty.")
            raise UcpException
        demisto.debug("Received access token from UCP Service")
        return method_id, access_token

    def __init__(
        self,
        service_account_dict: dict[str, str] | None,
        proxy: bool,
        verify: bool,
        base_url: str = "",
        headers: dict[str, str] | None = None,
        user_id: str = "",
    ):
        self.headers = headers
        self.proxy = proxy
        self.verify = verify
        self.authorized_http: Any = None
        self.base_url = base_url
        self.user_id = user_id

        # The method id is kept so the cached credentials can be invalidated and refreshed
        # after an auth error.
        self._ucp_method_id: str | None = None
        # ``_ucp_token`` is set only in UCP mode; its presence flags that the
        # credentials are token-based (no scopes/subject impersonation needed).
        self._ucp_token: str | None = None
        if not service_account_dict and should_use_ucp_auth():
            # In UCP (ConnectUs) mode the access token is provided by the
            # connection profile via the platform; fetch it instead of building
            # service-account credentials.
            # The subject (user to impersonate) is the instance ``user_id``; it is
            # forwarded to UCP so the issued token impersonates that user.
            self._ucp_method_id, self._ucp_token = self.get_ucp_access_token(subject=user_id or None)
            self.credentials = oauth2_credentials.Credentials(token=self._ucp_token)
            return

        try:
            self.credentials = service_account.Credentials.from_service_account_info(info=service_account_dict)
        except Exception:
            raise ValueError(COMMON_MESSAGES["JSON_PARSE_ERROR"])

    def set_authorized_http(self, scopes: list[str], subject: str | None = None, timeout: int = 60) -> None:
        """
        Set the http client from given subject and scopes.

        :param scopes: List of scopes needed to make request.
        :param subject: To link subject with credentials.
        :param timeout: Timeout value for request.

        :return: None.
        """
        # In UCP token mode the access token is already scoped and impersonates
        # the configured subject, so scopes/subject must not be re-applied
        # (OAuth2 token credentials do not support ``with_subject``).
        if not self._ucp_token:
            self.credentials = self.credentials.with_scopes(scopes)  # type: ignore[attr-defined]
            if subject:
                self.credentials = self.credentials.with_subject(subject)  # type: ignore[attr-defined]
        authorized_http = AuthorizedHttp(
            credentials=self.credentials, http=GSuiteClient.get_http_client(self.proxy, self.verify, timeout=timeout)
        )
        self.authorized_http = authorized_http

    def http_request(
        self,
        url_suffix: str = None,
        params: dict[str, Any] | None = None,
        method: str = "GET",
        body: dict[str, Any] | None = None,
        full_url: str | None = None,
    ) -> dict[str, Any]:
        """
        Makes an API call to URL using authorized HTTP.

        :param url_suffix: url_suffix of url.
        :param params: Parameters to pass in request url.
        :param method: Method to use while making http request.
        :param body: Request body.
        :param full_url: URL to consider for request when given url_suffix will be ignored.

        :return: response json.
        :raises DemistoException: If there is issues while making the http call.
        """
        encoded_params = f"?{urllib.parse.urlencode(params)}" if params else ""

        url = full_url

        if url_suffix:
            url = urljoin(self.base_url, url_suffix)

        url = f"{url}{encoded_params}"

        body = json.dumps(body) if body else None

        with GSuiteClient.http_exception_handler():
            response = self.authorized_http.request(headers=self.headers, method=method, uri=url, body=body)
            self._invalidate_ucp_credentials_on_auth_error(response)
            return GSuiteClient.validate_and_extract_response(response)

    def _invalidate_ucp_credentials_on_auth_error(self, response: tuple) -> None:
        """Invalidate cached UCP credentials when the response is an auth error.

        In UCP mode an expired/rotated credential is signalled by a 401/403.
        Dropping the cache entry forces the next request to fetch fresh
        credentials from the connection profile.

        :param response: Tuple of ``(httplib2.Response, content)``.
        :return: None
        """
        if not self._ucp_method_id:
            return
        try:
            status = response[0].status
        except (IndexError, AttributeError):
            return
        if status in (401, 403):
            invalidate_ucp_credentials(self._ucp_method_id)

    @staticmethod
    def handle_http_error(error: httplib2.socks.HTTPError) -> None:
        """
        Handle and raise DemistoException with respective message.

        :param error: HTTPError object.

        :return: None
        :raises DemistoException: raise DemistoException with respective error message.
        """
        if error.args and isinstance(error.args[0], tuple):
            error_status, error_msg = error.args[0][0], error.args[0][1].decode()
            if error_status == 407:  # Proxy Error
                raise DemistoException(COMMON_MESSAGES["PROXY_ERROR"])
            raise DemistoException(COMMON_MESSAGES["HTTP_ERROR"].format(error_status, error_msg))
        raise DemistoException(error)

    @staticmethod
    @contextmanager
    def http_exception_handler():
        """
        Exception handler for handling different exceptions while making http calls.

        :return: None
        :raises DemistoException: If there is any other issues while making the http call.
        """
        try:
            yield
        except httplib2.socks.HTTPError as error:
            GSuiteClient.handle_http_error(error)
        except exceptions.TransportError as error:
            if "proxyerror" in str(error).lower():
                raise DemistoException(COMMON_MESSAGES["PROXY_ERROR"])
            raise DemistoException(COMMON_MESSAGES["TRANSPORT_ERROR"].format(error))
        except exceptions.RefreshError as error:
            if error.args:
                # masking the token present in the error message
                error_msg = error.args[0]
                find_and_remove_sensitive_text(text=error_msg, pattern=r"(token:\s*)(\S+)")
                raise DemistoException(COMMON_MESSAGES["REFRESH_ERROR"].format(error_msg))
            raise DemistoException(error)
        except TimeoutError as error:
            raise DemistoException(COMMON_MESSAGES["TIMEOUT_ERROR"].format(error))
        except Exception as error:
            raise DemistoException(error)

    @staticmethod
    def get_http_client(proxy: bool, verify: bool, timeout: int = 60) -> httplib2.Http:
        """
        Validate proxy and prepares Http object.

        :param proxy: Boolean indicates whether to use proxy or not.
        :param verify: Boolean indicates whether to use ssl certification.
        :param timeout: Timeout value for request.

        :return: ProxyInfo object.
        :raises DemistoException: If there is any other issues while preparing proxy.
        """
        proxy_info = {}
        proxies = handle_proxy()
        if proxy:
            https_proxy = proxies["https"]
            if not https_proxy.startswith("https") and not https_proxy.startswith("http"):
                https_proxy = "https://" + https_proxy
            parsed_proxy = urllib.parse.urlparse(https_proxy)
            proxy_info = httplib2.ProxyInfo(
                proxy_type=httplib2.socks.PROXY_TYPE_HTTP,
                proxy_host=parsed_proxy.hostname,
                proxy_port=parsed_proxy.port,
                proxy_user=parsed_proxy.username,
                proxy_pass=parsed_proxy.password,
            )

        return httplib2.Http(proxy_info=proxy_info, disable_ssl_certificate_validation=not verify, timeout=timeout)

    @staticmethod
    def validate_and_extract_response(response: tuple[httplib2.Response, Any]) -> dict[str, Any]:
        """
        Prepares an error message based on status code and extract a response.

        :param response: Tuple containing httplib2.Response and content.

        :return: response extracted json.
        :raises DemistoException: If there is any other issues parsing response.
        """
        if response[0].status == 200 or response[0].status == 204:
            return GSuiteClient.safe_load_non_strict_json(response[1])

        status_code_message_map = {
            400: COMMON_MESSAGES["BAD_REQUEST_ERROR"],
            401: COMMON_MESSAGES["AUTHENTICATION_ERROR"],
            403: COMMON_MESSAGES["AUTHORIZATION_ERROR"],
            404: COMMON_MESSAGES["NOT_FOUND_ERROR"],
            429: COMMON_MESSAGES["TOO_MANY_REQUESTS_ERROR"],
            500: COMMON_MESSAGES["INTERNAL_SERVER_ERROR"],
        }

        try:
            # Depth details of error.
            demisto.debug(response[1].decode() if type(response[1]) is bytes else response[1])
            message = GSuiteClient.safe_load_non_strict_json(response[1]).get("error", {}).get("message", "")
        except ValueError:
            message = COMMON_MESSAGES["UNEXPECTED_ERROR"]

        if response[0].status in status_code_message_map:
            raise DemistoException(status_code_message_map[response[0].status].format(message))
        else:
            raise DemistoException(COMMON_MESSAGES["UNKNOWN_ERROR"].format(response[0].status, message))

    @staticmethod
    def safe_load_non_strict_json(json_string: str) -> dict[str, Any]:
        """
        Loads the JSON with non-strict mode.

        :param json_string: json string to parse.

        :return: Parsed dictionary.
        :raises ValueError: If there is any other issues while parsing json.
        """
        try:
            if json_string:
                return json.loads(json_string, strict=False)
            return {}
        except ValueError:
            raise ValueError(COMMON_MESSAGES["JSON_PARSE_ERROR"])

    @staticmethod
    def validate_set_boolean_arg(args: dict[str, Any], arg: str, arg_name: str | None = None) -> None:
        """
        Set and validate boolean arguments.

        :param args: dictionary containing arguments.
        :param arg: key containing boolean arg.
        :param arg_name: In case of arg name is different in command to set in exception.

        :return: None
        :raises ValueError: if boolean arg value is invalid.
        """
        if arg in args:
            try:
                args[arg] = argToBoolean(args[arg])
            except ValueError:
                raise ValueError(COMMON_MESSAGES["BOOLEAN_ERROR"].format(arg_name if arg_name else arg))

    @staticmethod
    def remove_empty_entities(d):
        """
        Recursively remove empty lists, empty dicts, or None elements from a dictionary.

        :param d: Input dictionary.
        :return: Dictionary with all empty lists, and empty dictionaries removed.
        """

        def empty(x):
            return x is None or x == {} or x == [] or x == ""

        if not isinstance(d, dict | list):
            return d
        elif isinstance(d, list):
            return [value for value in (GSuiteClient.remove_empty_entities(value) for value in d) if not empty(value)]
        else:
            return {
                key: value
                for key, value in ((key, GSuiteClient.remove_empty_entities(value)) for key, value in d.items())
                if not empty(value)
            }

    @staticmethod
    def validate_get_int(max_results: str | None, message: str, limit: int = 0) -> int | None:
        """
        Validate and convert string max_results to integer.

        :param max_results: max results in string.
        :param message: Message to display when exception raised.
        :param limit: If max_results > limit raise the exception.

        :return: int max_results
        :raises ValueError: if max_results is not a integer and < 0.
        """
        if max_results:
            try:
                max_results_int = int(max_results)
                if max_results_int <= 0:
                    raise ValueError
                if limit and max_results_int > limit:
                    raise ValueError
                return max_results_int
            except ValueError:
                raise ValueError(message)
        return None

    @staticmethod
    def strip_dict(args: dict[str, str]) -> dict[str, str]:
        """
        Remove leading and trailing white spaces from dictionary values and remove empty entries.

        :param args: Arguments dict.
        :return: Dictionary with whitespaces and empty entries removed.
        """
        return {key: value.strip() for (key, value) in args.items() if value and value.strip()}

README

To use the common GSuite API logic (authentication and API requests), run the following command to import the GSuiteApiModule.

def main():
    ...


from GSuiteApiModule import *  # noqa: E402

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

Then, the GSuiteClient will be available for usage. For examples, see the G Suite Admin or Google Drive or Google Calendar integrations.