From b3b1e066eb09a95c4e8d5610e9cfa3760ed9f15a Mon Sep 17 00:00:00 2001 From: Tal Date: Sat, 16 Nov 2024 18:26:35 +0200 Subject: [PATCH] chore: servicenow should support oauth to obtain token (#2502) --- keep/api/utils/email_utils.py | 10 +- keep/functions/__init__.py | 2 +- keep/iohandler/iohandler.py | 12 +- .../servicenow_provider.py | 154 +++++++++++++++--- keep/workflowmanager/workflowscheduler.py | 48 +++--- 5 files changed, 176 insertions(+), 50 deletions(-) diff --git a/keep/api/utils/email_utils.py b/keep/api/utils/email_utils.py index d76001495..34e708fba 100644 --- a/keep/api/utils/email_utils.py +++ b/keep/api/utils/email_utils.py @@ -33,17 +33,22 @@ class EmailTemplates(enum.Enum): FROM_EMAIL = config("SENDGRID_FROM_EMAIL", default="platform@keephq.dev") API_KEY = config("SENDGRID_API_KEY", default=None) CC = config("SENDGRID_CC", default="founders@keephq.dev") +KEEP_EMAILS_ENABLED = config("KEEP_EMAILS_ENABLED", default=False, cast=bool) def send_email( to_email: str, template_id: EmailTemplates, **kwargs, -): +) -> bool: + if not KEEP_EMAILS_ENABLED: + logger.debug("Emails are disabled, skipping sending email") + return False + # that's ok on OSS if not API_KEY: logger.debug("No SendGrid API key, skipping sending email") - return + return False message = Mail(from_email=FROM_EMAIL, to_emails=to_email) message.template_id = template_id.value @@ -55,6 +60,7 @@ def send_email( sg = SendGridAPIClient(API_KEY) sg.send(message) logger.info(f"Email sent to {to_email} with template {template_id}") + return True except Exception as e: logger.error( f"Failed to send email to {to_email} with template {template_id}: {e}" diff --git a/keep/functions/__init__.py b/keep/functions/__init__.py index 6a04469c9..503021a8e 100644 --- a/keep/functions/__init__.py +++ b/keep/functions/__init__.py @@ -115,7 +115,7 @@ def to_timestamp(dt: datetime.datetime | str = "") -> int: def datetime_compare(t1: datetime = None, t2: datetime = None) -> float: - if t1 is None or t2 is None: + if not t1 or not t2: return 0 diff = (t1 - t2).total_seconds() / 3600 return diff diff --git a/keep/iohandler/iohandler.py b/keep/iohandler/iohandler.py index 88a857aff..7f0f22fec 100644 --- a/keep/iohandler/iohandler.py +++ b/keep/iohandler/iohandler.py @@ -330,7 +330,17 @@ def _parse(self, tree): .replace("\n", "\\n") ) t = self._encode_single_quotes_in_double_quotes(t) - tree = ast.parse(t) + try: + tree = ast.parse(t) + except Exception: + # For strings where ' is used as the delimeter and we failed to escape all ' in the string + # @tb: again, this is not ideal but it's best effort... + t = ( + t.replace("('", '("') + .replace("')", '")') + .replace("',", '",') + ) + tree = ast.parse(t) else: # for strings such as "45%\n", we need to escape tree = ast.parse(token.encode("unicode_escape")) diff --git a/keep/providers/servicenow_provider/servicenow_provider.py b/keep/providers/servicenow_provider/servicenow_provider.py index c9c1ee967..f1afd1776 100644 --- a/keep/providers/servicenow_provider/servicenow_provider.py +++ b/keep/providers/servicenow_provider/servicenow_provider.py @@ -45,6 +45,25 @@ class ServicenowProviderAuthConfig: } ) + # @tb: based on this https://www.servicenow.com/community/developer-blog/oauth-2-0-with-inbound-rest/ba-p/2278926 + client_id: str = dataclasses.field( + metadata={ + "required": False, + "description": "The client ID to use OAuth 2.0 based authentication", + "sensitive": False, + }, + default="", + ) + + client_secret: str = dataclasses.field( + metadata={ + "required": False, + "description": "The client secret to use OAuth 2.0 based authentication", + "sensitive": True, + }, + default="", + ) + class ServicenowProvider(BaseTopologyProvider): """Manage ServiceNow tickets.""" @@ -65,6 +84,33 @@ def __init__( self, context_manager: ContextManager, provider_id: str, config: ProviderConfig ): super().__init__(context_manager, provider_id, config) + self._access_token = None + if ( + self.authentication_config.client_id + and self.authentication_config.client_secret + ): + url = f"{self.authentication_config.service_now_base_url}/oauth_token.do" + payload = { + "grant_type": "password", + "username": self.authentication_config.username, + "password": self.authentication_config.password, + "client_id": self.authentication_config.client_id, + "client_secret": self.authentication_config.client_secret, + } + response = requests.post( + url, + json=payload, + ) + if response.ok: + self._access_token = response.json().get("access_token") + else: + self.logger.error( + "Failed to get access token", + extra={ + "response": response.text, + "status_code": response.status_code, + }, + ) @property def service_now_base_url(self): @@ -80,15 +126,23 @@ def validate_scopes(self): try: self.logger.info("Validating ServiceNow scopes") url = f"{self.authentication_config.service_now_base_url}/api/now/table/sys_user_role?sysparm_query=user_name={self.authentication_config.username}" - response = requests.get( - url, - auth=HTTPBasicAuth( - self.authentication_config.username, - self.authentication_config.password, - ), - verify=False, - timeout=10, - ) + if self._access_token: + response = requests.get( + url, + headers={"Authorization": f"Bearer {self._access_token}"}, + verify=False, + timeout=10, + ) + else: + response = requests.get( + url, + auth=HTTPBasicAuth( + self.authentication_config.username, + self.authentication_config.password, + ), + verify=False, + timeout=10, + ) try: response.raise_for_status() @@ -131,13 +185,57 @@ def validate_config(self): **self.config.authentication ) + def _query( + self, + table_name: str, + get_incidents: bool = False, + incident_id: str = None, + **kwargs: dict, + ): + request_url = f"{self.authentication_config.service_now_base_url}/api/now/table/{table_name}" + headers = {"Content-Type": "application/json", "Accept": "application/json"} + auth = ( + ( + self.authentication_config.username, + self.authentication_config.password, + ) + if not self._access_token + else None + ) + if self._access_token: + headers["Authorization"] = f"Bearer {self._access_token}" + + response = requests.get( + request_url, + headers=headers, + auth=auth, + params=kwargs, + verify=False, + timeout=10, + ) + + if not response.ok: + self.logger.error( + f"Failed to query {table_name}", + extra={"status_code": response.status_code, "response": response.text}, + ) + return [] + + return response.json().get("result", []) + def pull_topology(self) -> list[TopologyServiceInDto]: - # TODO: in scable, we'll need to use pagination around here + # TODO: in scale, we'll need to use pagination around here headers = {"Content-Type": "application/json", "Accept": "application/json"} auth = ( - self.authentication_config.username, - self.authentication_config.password, + ( + self.authentication_config.username, + self.authentication_config.password, + ) + if not self._access_token + else None ) + if self._access_token: + headers["Authorization"] = f"Bearer {self._access_token}" topology = [] self.logger.info( "Pulling topology", extra={"tenant_id": self.context_manager.tenant_id} @@ -253,7 +351,16 @@ def dispose(self): def _notify(self, table_name: str, payload: dict = {}, **kwargs: dict): # Create ticket headers = {"Content-Type": "application/json", "Accept": "application/json"} - + auth = ( + ( + self.authentication_config.username, + self.authentication_config.password, + ) + if not self._access_token + else None + ) + if self._access_token: + headers["Authorization"] = f"Bearer {self._access_token}" # otherwise, create the ticket if not table_name: raise ProviderException("Table name is required") @@ -271,10 +378,7 @@ def _notify(self, table_name: str, payload: dict = {}, **kwargs: dict): # HTTP request response = requests.post( url, - auth=( - self.authentication_config.username, - self.authentication_config.password, - ), + auth=auth, headers=headers, data=json.dumps(payload), verify=False, @@ -302,12 +406,20 @@ def _notify(self, table_name: str, payload: dict = {}, **kwargs: dict): def _notify_update(self, table_name: str, ticket_id: str, fingerprint: str): url = f"{self.authentication_config.service_now_base_url}/api/now/table/{table_name}/{ticket_id}" headers = {"Content-Type": "application/json", "Accept": "application/json"} - response = requests.get( - url, - auth=( + auth = ( + ( self.authentication_config.username, self.authentication_config.password, - ), + ) + if self._access_token + else None + ) + if self._access_token: + headers["Authorization"] = f"Bearer {self._access_token}" + + response = requests.get( + url, + auth=auth, headers=headers, verify=False, ) diff --git a/keep/workflowmanager/workflowscheduler.py b/keep/workflowmanager/workflowscheduler.py index 5ed3d139c..fe402a32e 100644 --- a/keep/workflowmanager/workflowscheduler.py +++ b/keep/workflowmanager/workflowscheduler.py @@ -583,32 +583,30 @@ def _finish_workflow_execution( or previous_execution.status != WorkflowStatus.ERROR.value ): workflow = get_workflow_db(tenant_id=tenant_id, workflow_id=workflow_id) - self.logger.info( - f"Sending email to {workflow.created_by} for failed workflow {workflow_id}" - ) - - # send the email (commented out) try: - # from keep.api.core.config import config - # from keep.api.utils.email_utils import EmailTemplates, send_email - # TODO - should be handled - # keep_platform_url = config( - # "KEEP_PLATFORM_URL", default="https://platform.keephq.dev" - # ) - # error_logs_url = f"{keep_platform_url}/workflows/{workflow_id}/runs/{workflow_execution_id}" - # send_email( - # to_email=workflow.created_by, - # template_id=EmailTemplates.WORKFLOW_RUN_FAILED, - # workflow_id=workflow_id, - # workflow_name=workflow.name, - # workflow_execution_id=workflow_execution_id, - # error=error, - # url=error_logs_url, - # ) - # self.logger.info( - # f"Email sent to {workflow.created_by} for failed workflow {workflow_id}" - # ) - pass + from keep.api.core.config import config + from keep.api.utils.email_utils import EmailTemplates, send_email + + keep_platform_url = config( + "KEEP_PLATFORM_URL", default="https://platform.keephq.dev" + ) + error_logs_url = f"{keep_platform_url}/workflows/{workflow_id}/runs/{workflow_execution_id}" + self.logger.debug( + f"Sending email to {workflow.created_by} for failed workflow {workflow_id}" + ) + email_sent = send_email( + to_email=workflow.created_by, + template_id=EmailTemplates.WORKFLOW_RUN_FAILED, + workflow_id=workflow_id, + workflow_name=workflow.name, + workflow_execution_id=workflow_execution_id, + error=error, + url=error_logs_url, + ) + if email_sent: + self.logger.info( + f"Email sent to {workflow.created_by} for failed workflow {workflow_id}" + ) except Exception as e: self.logger.error( f"Failed to send email to {workflow.created_by} for failed workflow {workflow_id}: {e}"