Skip to content

Commit

Permalink
chore: servicenow should support oauth to obtain token (#2502)
Browse files Browse the repository at this point in the history
  • Loading branch information
talboren authored Nov 16, 2024
1 parent 4f6b0b4 commit b3b1e06
Show file tree
Hide file tree
Showing 5 changed files with 176 additions and 50 deletions.
10 changes: 8 additions & 2 deletions keep/api/utils/email_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,17 +33,22 @@ class EmailTemplates(enum.Enum):
FROM_EMAIL = config("SENDGRID_FROM_EMAIL", default="[email protected]")
API_KEY = config("SENDGRID_API_KEY", default=None)
CC = config("SENDGRID_CC", default="[email protected]")
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
Expand All @@ -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}"
Expand Down
2 changes: 1 addition & 1 deletion keep/functions/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
12 changes: 11 additions & 1 deletion keep/iohandler/iohandler.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"))
Expand Down
154 changes: 133 additions & 21 deletions keep/providers/servicenow_provider/servicenow_provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand All @@ -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):
Expand All @@ -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()
Expand Down Expand Up @@ -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}
Expand Down Expand Up @@ -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")
Expand All @@ -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,
Expand Down Expand Up @@ -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,
)
Expand Down
48 changes: 23 additions & 25 deletions keep/workflowmanager/workflowscheduler.py
Original file line number Diff line number Diff line change
Expand Up @@ -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}"
Expand Down

0 comments on commit b3b1e06

Please sign in to comment.