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.