Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

chore: servicenow should support oauth to obtain token #2502

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading