<NME> api.py <BEF> # Copyright (c) LinkedIn Corporation. All rights reserved. Licensed under the BSD-2 Clause license. # See LICENSE in the project root for license information. from gevent import spawn, sleep, socket, Timeout, monkey monkey.patch_all() # NOQA import msgpack import time import hmac import hashlib import base64 import re import os import random import datetime import logging import jinja2 from jinja2.sandbox import SandboxedEnvironment from urllib.parse import parse_qs import ujson from falcon import (HTTP_200, HTTP_201, HTTP_204, HTTP_503, HTTPBadRequest, HTTPNotFound, HTTPUnauthorized, HTTPForbidden, HTTPFound, HTTPInternalServerError, API) from falcon_cors import CORS from sqlalchemy.exc import IntegrityError, InternalError, OperationalError import falcon.uri import falcon from collections import defaultdict from streql import equals from . import db from . import utils from . import cache from . import ui from . import app_stats from .config import load_config from iris.vendors.iris_slack import iris_slack from iris.sender import auditlog from iris.sender.quota import (get_application_quotas_query, insert_application_quota_query, required_quota_keys, quota_int_keys) from iris.custom_import import import_custom_module from iris.custom_incident_handler import CustomIncidentHandlerDispatcher from .constants import ( XFRAME, XCONTENTTYPEOPTIONS, XXSSPROTECTION, PRIORITY_PRECEDENCE_MAP ) from .plugins import init_plugins, find_plugin from .validators import init_validators, run_validation, IrisValidationException logger = logging.getLogger(__name__) operators = { '': '%s = %s', 'eq': '%s = %s', 'in': '%s in %s', 'ne': '%s != %s', 'gt': '%s > %s', 'ge': '%s >= %s', 'lt': '%s < %s', 'le': '%s <= %s', 'contains': '%s LIKE CONCAT("%%%%", %s, "%%%%")', 'startswith': '%s LIKE CONCAT(%s, "%%%%")', 'endswith': '%s LIKE CONCAT("%%%%", %s)', } def ts_to_sql_datetime(ts): return 'FROM_UNIXTIME(%s)' % ts filter_escaped_value_transforms = { 'updated': ts_to_sql_datetime, 'created': ts_to_sql_datetime, 'sent': ts_to_sql_datetime, } message_columns = { 'id': '`message`.`id` as `id`', 'active': '`message`.`active` as `active`', 'batch': '`message`.`batch` as `batch`', 'created': 'UNIX_TIMESTAMP(`message`.`created`) as `created`', 'sent': 'UNIX_TIMESTAMP(`message`.`sent`) as `sent`', 'destination': '`message`.`destination` as `destination`', 'subject': '`message`.`subject` as `subject`', 'incident_id': '`message`.`incident_id` as `incident_id`', 'mode': '`mode`.`name` as `mode`', 'application': '`application`.`name` as `application`', 'priority': '`priority`.`name` as `priority`', 'target': '`target`.`name` as `target`', 'body': '`message`.`body` as `body`', 'mode_changed': 'exists(SELECT 1 FROM `message_changelog` WHERE `message_id` = `message`.`id` AND `change_type` = %(mode_change)s) as mode_changed', 'target_changed': 'exists(SELECT 1 FROM `message_changelog` WHERE `message_id` = `message`.`id` AND `change_type` = %(target_change)s) as target_changed' } message_filters = { 'id': '`message`.`id`', 'active': '`message`.`active`', 'batch': '`message`.`batch`', 'created': '`message`.`created`', 'sent': '`message`.`sent`', 'destination': '`message`.`destination`', 'subject': '`message`.`subject`', 'incident_id': '`message`.`incident_id`', 'mode': '`mode`.`name`', 'application': '`application`.`name`', 'priority': '`priority`.`name`', 'target': '`target`.`name`', 'body': '`message`.`body`', } message_filter_types = { 'id': int, 'created': int, 'sent': int, } message_query = '''SELECT %s FROM `message` JOIN `priority` ON `message`.`priority_id` = `priority`.`id` JOIN `application` ON `message`.`application_id` = `application`.`id` JOIN `mode` ON `message`.`mode_id` = `mode`.`id` JOIN `target` ON `message`.`target_id`=`target`.`id`''' single_message_query = '''SELECT `message`.`id` as `id`, `message`.`active` as `active`, `message`.`batch` as `batch`, `message`.`body` as `body`, UNIX_TIMESTAMP(`message`.`created`) as `created`, UNIX_TIMESTAMP(`message`.`sent`) as `sent`, `message`.`destination` as `destination`, `message`.`subject` as `subject`, `message`.`incident_id` as `incident_id`, `mode`.`name` as `mode`, `application`.`name` as `application`, `priority`.`name` as `priority`, `target`.`name` as `target`, `twilio_delivery_status`.`status` as `twilio_delivery_status`, `generic_message_sent_status`.`status` as `generic_message_sent_status` FROM `message` JOIN `priority` ON `message`.`priority_id` = `priority`.`id` JOIN `application` ON `message`.`application_id` = `application`.`id` JOIN `mode` ON `message`.`mode_id` = `mode`.`id` JOIN `target` ON `message`.`target_id`=`target`.`id` LEFT JOIN `twilio_delivery_status` ON `twilio_delivery_status`.`message_id` = `message`.`id` LEFT JOIN `generic_message_sent_status` ON `generic_message_sent_status`.`message_id` = `message`.`id` WHERE `message`.`id` = %s''' message_audit_log_query = '''SELECT `id`, `date`, `old`, `new`, `change_type`, `description` FROM `message_changelog` WHERE `message_id` = %s ORDER BY `date` DESC''' incident_columns = { 'id': '`incident`.`id` as `id`', 'plan': '`plan`.`name` as `plan`', 'plan_id': '`incident`.`plan_id` as `plan_id`', 'active': '`incident`.`active` as `active`', 'updated': 'UNIX_TIMESTAMP(`incident`.`updated`) as `updated`', 'application': '`application`.`name` as `application`', 'context': '`incident`.`context` as `context`', 'created': 'UNIX_TIMESTAMP(`incident`.`created`) as `created`', 'owner': '`target`.`name` as `owner`', 'current_step': '`incident`.`current_step` as `current_step`', 'title_variable_name': '`template_variable`.`name` as `title_variable_name`', 'resolved': '`incident`.`resolved` as `resolved`' } incident_filters = { 'id': '`incident`.`id`', 'plan': '`plan`.`name`', 'plan_id': '`incident`.`plan_id`', 'active': '`incident`.`active`', 'updated': '`incident`.`updated`', 'application': '`application`.`name`', 'context': '`incident`.`context`', 'created': '`incident`.`created`', 'owner': '`target`.`name`', 'current_step': '`incident`.`current_step`', 'resolved': '`incident`.`resolved` as `resolved`' } incident_filter_types = { 'id': int, 'plan_id': int, 'updated': int, 'created': int, 'current_step': int, } incident_query = '''SELECT DISTINCT %s FROM `incident` JOIN `plan` ON `incident`.`plan_id` = `plan`.`id` LEFT OUTER JOIN `target` ON `incident`.`owner_id` = `target`.`id` JOIN `application` ON `incident`.`application_id` = `application`.`id` LEFT OUTER JOIN `template_variable` ON (`template_variable`.`application_id` = `application`.`id` AND `template_variable`.`title_variable` = 1)''' single_incident_query = '''SELECT `incident`.`id` as `id`, `incident`.`plan_id` as `plan_id`, `plan`.`name` as `plan`, UNIX_TIMESTAMP(`incident`.`created`) as `created`, UNIX_TIMESTAMP(`incident`.`updated`) as `updated`, `incident`.`context` as `context`, `target`.`name` as `owner`, `application`.`name` as `application`, `incident`.`current_step` as `current_step`, `incident`.`active` as `active`, `incident`.`resolved` as `resolved` FROM `incident` JOIN `plan` ON `incident`.`plan_id` = `plan`.`id` LEFT OUTER JOIN `target` ON `incident`.`owner_id` = `target`.`id` JOIN `application` ON `incident`.`application_id` = `application`.`id` LEFT OUTER JOIN `template_variable` ON (`template_variable`.`application_id` = `application`.`id` AND `template_variable`.`title_variable` = 1) WHERE `incident`.`id` = %s''' single_incident_query_steps = '''SELECT `message`.`id` as `id`, `target`.`name` as `name`, `mode`.`name` as `mode`, `priority`.`name` as `priority`, UNIX_TIMESTAMP(`message`.`created`) as `created`, UNIX_TIMESTAMP(`message`.`sent`) as `sent`, `plan_notification`.`step` as `step`, exists(SELECT 1 FROM `message_changelog` WHERE `message_id` = `message`.`id` AND `change_type` = %s) as mode_changed, exists(SELECT 1 FROM `message_changelog` WHERE `message_id` = `message`.`id` AND `change_type` = %s) as target_changed FROM `message` JOIN `priority` ON `message`.`priority_id` = `priority`.`id` JOIN `mode` ON `message`.`mode_id` = `mode`.`id` JOIN `target` ON `message`.`target_id` = `target`.`id` JOIN `plan_notification` ON `message`.`plan_notification_id` = `plan_notification`.`id` WHERE `message`.`incident_id` = %s ORDER BY `message`.`sent`''' single_incident_query_comments = ''' SELECT `target`.`name` AS `author`, `comment`.`created`, `comment`.`content` FROM `comment` JOIN `target` ON `user_id` = `target`.`id` WHERE `comment`.`incident_id` = %s ''' plan_columns = { 'id': '`plan`.`id` as `id`', 'name': '`plan`.`name` as `name`', 'threshold_window': '`plan`.`threshold_window` as `threshold_window`', 'threshold_count': '`plan`.`threshold_count` as `threshold_count`', 'aggregation_window': '`plan`.`aggregation_window` as `aggregation_window`', 'aggregation_reset': '`plan`.`aggregation_reset` as `aggregation_reset`', 'tracking_type': '`plan`.`tracking_type` as `tracking_type`', 'tracking_key': '`plan`.`tracking_key` as `tracking_key`', 'tracking_template': '`plan`.`tracking_template` as `tracking_template`', 'description': '`plan`.`description` as `description`', 'created': 'UNIX_TIMESTAMP(`plan`.`created`) as `created`', 'creator': '`target`.`name` as `creator`', 'active': 'IF(`plan_active`.`plan_id` IS NULL, FALSE, TRUE) as `active`', } plan_filters = { 'id': '`plan`.`id`', 'name': '`plan`.`name`', 'threshold_window': '`plan`.`threshold_window`', 'threshold_count': '`plan`.`threshold_count`', 'aggregation_window': '`plan`.`aggregation_window`', 'aggregation_reset': '`plan`.`aggregation_reset`', 'description': '`plan`.`description`', 'created': '`plan`.`created`', 'creator': '`target`.`name`', 'active': '`plan_active`.`plan_id`', } plan_filter_types = { 'id': int, 'created': int, 'threshold_count': int, 'threshold_window': int, 'aggregation_window': int, 'aggregation_reset': int, } plan_query = '''SELECT DISTINCT %s FROM `plan` JOIN `target` ON `plan`.`user_id` = `target`.`id` LEFT OUTER JOIN `plan_active` ON `plan`.`id` = `plan_active`.`plan_id`''' plan_target_query = '''SELECT `plan_id` FROM `plan_notification` JOIN `target` ON `plan_notification`.`target_id` = `target`.`id`''' plan_target_fields = [ 'target', 'target__contains', 'target__startswith', 'target__endswith' ] plan_target_filters = { 'target': '`target`.`name`' } single_plan_query = '''SELECT `plan`.`id` as `id`, `plan`.`name` as `name`, `plan`.`threshold_window` as `threshold_window`, `plan`.`threshold_count` as `threshold_count`, `plan`.`aggregation_window` as `aggregation_window`, `plan`.`aggregation_reset` as `aggregation_reset`, `plan`.`description` as `description`, UNIX_TIMESTAMP(`plan`.`created`) as `created`, `target`.`name` as `creator`, IF(`plan_active`.`plan_id` IS NULL, FALSE, TRUE) as `active`, `plan`.`tracking_type` as `tracking_type`, `plan`.`tracking_key` as `tracking_key`, `plan`.`tracking_template` as `tracking_template` FROM `plan` JOIN `target` ON `plan`.`user_id` = `target`.`id` LEFT OUTER JOIN `plan_active` ON `plan`.`id` = `plan_active`.`plan_id`''' single_plan_query_steps = '''SELECT `plan_notification`.`id` as `id`, `plan_notification`.`step` as `step`, `plan_notification`.`repeat` as `repeat`, `plan_notification`.`wait` as `wait`, `plan_notification`.`optional` as `optional`, `target_role`.`name` as `role`, `target`.`name` as `target`, `plan_notification`.`template` as `template`, `priority`.`name` as `priority`, `plan_notification`.`dynamic_index` AS `dynamic_index` FROM `plan_notification` LEFT OUTER JOIN `target` ON `plan_notification`.`target_id` = `target`.`id` LEFT OUTER JOIN `target_role` ON `plan_notification`.`role_id` = `target_role`.`id` JOIN `priority` ON `plan_notification`.`priority_id` = `priority`.`id` WHERE `plan_notification`.`plan_id` = %s ORDER BY `plan_notification`.`step`''' template_columns = { 'id': '`template`.`id` as `id`', 'name': '`template`.`name` as `name`', 'creator': '`target`.`name` as `creator`', 'created': 'UNIX_TIMESTAMP(`template`.`created`) as `created`', 'active': 'IF(`template_active`.`template_id` IS NULL, FALSE, TRUE) as `active`', } template_filters = { 'id': '`template`.`id`', 'name': '`template`.`name`', 'creator': '`target`.`name`', 'created': '`template`.`created`', 'active': '`template_active`.`template_id`', } template_filter_types = { 'id': int, 'created': int, } template_query = '''SELECT %s FROM `template` JOIN `target` ON `template`.`user_id`=`target`.`id` LEFT OUTER JOIN `template_active` ON `template`.`id` = `template_active`.`template_id`''' single_template_query = '''SELECT `template`.`id` as `id`, `template`.`name` as `name`, IF(`template_active`.`template_id` IS NULL, FALSE, TRUE) as `active`, `target`.`name` as `creator`, UNIX_TIMESTAMP(`template`.`created`) as `created`, `application`.`name` as `application`, `mode`.`name` as `mode`, `template_content`.`subject` as `subject`, `template_content`.`body` as `body` FROM `template` JOIN `target` ON `template`.`user_id`=`target`.`id` LEFT OUTER JOIN `template_active` ON `template`.`id` = `template_active`.`template_id` JOIN `template_content` ON `template`.`id` = `template_content`.`template_id` JOIN `application` ON `template_content`.`application_id` = `application`.`id` JOIN `mode` ON `template_content`.`mode_id` = `mode`.`id`''' single_template_query_plans = '''SELECT DISTINCT `plan_active`.`plan_id` as `id`, `plan_active`.`name` as `name` FROM `plan_notification` JOIN `plan_active` ON `plan_notification`.`plan_id` = `plan_active`.`plan_id` WHERE `plan_notification`.`template` = %s''' insert_plan_query = '''INSERT INTO `plan` ( `user_id`, `name`, `created`, `description`, `step_count`, `threshold_window`, `threshold_count`, `aggregation_window`, `aggregation_reset`, `tracking_key`, `tracking_type`, `tracking_template` ) VALUES ( (SELECT `id` FROM `target` where `name` = :creator AND `type_id` = ( SELECT `id` FROM `target_type` WHERE `name` = 'user' )), :name, :created, :description, :step_count, :threshold_window, :threshold_count, :aggregation_window, :aggregation_reset, :tracking_key, :tracking_type, :tracking_template )''' insert_plan_step_query = '''INSERT INTO `plan_notification` ( `plan_id`, `step`, `priority_id`, `target_id`, `template`, `role_id`, `repeat`, `wait`, `optional` ) VALUES ( :plan_id, :step, :priority_id, (SELECT `target`.`id` FROM `target` WHERE `target`.`name` = :target AND `target`.`type_id` = (SELECT `target_role`.`type_id` FROM `target_role` WHERE `id` = :role_id) ), :template, :role_id, :repeat, :wait, :optional )''' insert_dynamic_step_query = '''INSERT INTO `plan_notification` ( `plan_id`, `step`, `priority_id`, `template`, `repeat`, `wait`, `dynamic_index`, `optional` ) VALUES ( :plan_id, :step, :priority_id, :template, :repeat, :wait, :dynamic_index, :optional )''' reprioritization_setting_query = '''SELECT `target`.`name` as `target`, `mode_src`.`name` as `src_mode`, `mode_dst`.`name` as `dst_mode`, `target_reprioritization`.`count` as `count`, `target_reprioritization`.`duration` as `duration` FROM `target_reprioritization` JOIN `target` ON `target`.`id` = `target_reprioritization`.`target_id` JOIN `target_type` on `target`.`type_id` = `target_type`.`id` JOIN `mode` `mode_src` ON `mode_src`.`id` = `target_reprioritization`.`src_mode_id` JOIN `mode` `mode_dst` ON `mode_dst`.`id` = `target_reprioritization`.`dst_mode_id` WHERE `target`.`name` = %s AND `target_type`.`name` = 'user' ''' update_reprioritization_settings_query = '''INSERT INTO target_reprioritization ( `target_id`, `src_mode_id`, `dst_mode_id`, `count`, `duration` ) VALUES ( (SELECT `id` FROM `target` WHERE `name` = :target AND `type_id` = ( SELECT `id` FROM `target_type` WHERE `name` = 'user' )), :src_mode_id, :dst_mode_id, :count, :duration ) ON DUPLICATE KEY UPDATE `dst_mode_id`=:dst_mode_id, `count`=:count, `duration`=:duration''' delete_reprioritization_settings_query = '''DELETE FROM `target_reprioritization` WHERE `target_id` = (SELECT `id` from `target` where `name` = :target_name AND `type_id` = ( SELECT `id` FROM `target_type` WHERE `name` = 'user' )) AND `src_mode_id` = (SELECT `id` from `mode` where `name` = :mode_name)''' get_user_modes_query = '''SELECT `priority`.`name` as priority, `mode`.`name` as mode from `priority` JOIN `target_mode` on `target_mode`.`priority_id` = `priority`.`id` JOIN `mode` on `mode`.`id` = `target_mode`.`mode_id` JOIN `target` on `target`.`id` = `target_mode`.`target_id` WHERE `target`.`name` = :username''' get_target_application_modes_query = '''SELECT `priority`.`name` as priority, `mode`.`name` as mode from `priority` JOIN `target_application_mode` on `target_application_mode`.`priority_id` = `priority`.`id` JOIN `mode` on `mode`.`id` = `target_application_mode`.`mode_id` JOIN `target` on `target`.`id` = `target_application_mode`.`target_id` JOIN `target_type` on `target`.`type_id` = `target_type`.`id` JOIN `application` on `application`.`id` = `target_application_mode`.`application_id` WHERE `target`.`name` = :username AND `target_type`.`name` = 'user' AND `application`.`name` = :app''' get_all_users_app_modes_query = '''SELECT `application`.`name` as application, `priority`.`name` as priority, `mode`.`name` as mode from `priority` JOIN `target_application_mode` on `target_application_mode`.`priority_id` = `priority`.`id` JOIN `mode` on `mode`.`id` = `target_application_mode`.`mode_id` JOIN `application` on `application`.`id` = `target_application_mode`.`application_id` WHERE `target_application_mode`.`target_id` = %s ''' get_default_application_modes_query = ''' SELECT `priority`.`name` as priority, `mode`.`name` as mode FROM `default_application_mode` JOIN `mode` on `mode`.`id` = `default_application_mode`.`mode_id` JOIN `priority` on `priority`.`id` = `default_application_mode`.`priority_id` JOIN `application` on `application`.`id` = `default_application_mode`.`application_id` WHERE `application`.`name` = %s''' get_supported_application_modes_query = ''' SELECT `mode`.`name` FROM `mode` JOIN `application_mode` on `mode`.`id` = `application_mode`.`mode_id` WHERE `application_mode`.`application_id` = %s ''' insert_user_modes_query = '''INSERT INTO `target_mode` (`priority_id`, `target_id`, `mode_id`) VALUES ( (SELECT `id` from `priority` WHERE `name` = :priority), (SELECT `id` from `target` WHERE `name` = :name AND `type_id` = ( SELECT `id` FROM `target_type` WHERE `name` = 'user' )), (SELECT `id` from `mode` WHERE `name` = :mode)) ON DUPLICATE KEY UPDATE `target_mode`.`mode_id` = (SELECT `id` from `mode` WHERE `name` = :mode)''' delete_user_modes_query = '''DELETE FROM `target_mode` WHERE `target_id` = (SELECT `id` from `target` WHERE `name` = :name AND `type_id` = (SELECT `id` FROM `target_type` WHERE `name` = 'user')) AND `priority_id` = (SELECT `id` from `priority` WHERE `name` = :priority)''' insert_target_application_modes_query = '''INSERT INTO `target_application_mode` (`priority_id`, `target_id`, `mode_id`, `application_id`) VALUES ( (SELECT `id` from `priority` WHERE `name` = :priority), (SELECT `id` from `target` WHERE `name` = :name AND `target`.`type_id` = ( SELECT `id` FROM `target_type` WHERE `name` = 'user' )), (SELECT `id` from `mode` WHERE `name` = :mode), (SELECT `id` from `application` WHERE `name` = :app)) ON DUPLICATE KEY UPDATE `target_application_mode`.`mode_id` = (SELECT `id` from `mode` WHERE `name` = :mode)''' delete_target_application_modes_query = '''DELETE FROM `target_application_mode` WHERE `target_id` = (SELECT `id` from `target` WHERE `name` = :name AND `type_id` = ( SELECT `id` FROM `target_type` WHERE `name` = 'user' )) AND `priority_id` = (SELECT `id` from `priority` WHERE `name` = :priority) AND `application_id` = (SELECT `id` from `application` WHERE `name` = :app)''' insert_user_modes_template_override_query = '''INSERT INTO `mode_template_override` (`target_id`, `mode_id`) VALUES ( (SELECT `id` from `target` WHERE `name` = %s AND `type_id` = ( SELECT `id` FROM `target_type` WHERE `name` = 'user' )), (SELECT `id` from `mode` WHERE `name` = 'sms')) ON DUPLICATE KEY UPDATE target_id=target_id''' delete_user_modes_template_override_query = '''DELETE FROM `mode_template_override` WHERE `target_id` = (SELECT `id` from `target` WHERE `name` = %s AND `type_id` = (SELECT `id` FROM `target_type` WHERE `name` = 'user')) AND `mode_id` = (SELECT `id` from `mode` WHERE `name` = 'sms')''' get_applications_query = '''SELECT `id`, `name`, `context_template`, `sample_context`, `summary_template`, `mobile_template` FROM `application` WHERE `auth_only` is False''' get_vars_query = 'SELECT `name`, `required`, `title_variable` FROM `template_variable` WHERE `application_id` = %s ORDER BY `required` DESC, `name` ASC' get_allowed_roles_query = '''SELECT `target_role`.`id` FROM `target_role` JOIN `target_type` ON `target_type`.`id` = `target_role`.`type_id` JOIN `target` ON `target`.`type_id` = `target_type`.`id` WHERE `target`.`name` = :target''' check_username_admin_query = '''SELECT `user`.`admin` FROM `user` JOIN `target` ON `target`.`id` = `user`.`target_id` JOIN `target_type` ON `target_type`.`id` = `target`.`type_id` WHERE `target`.`name` = %s AND `target_type`.`name` = "user"''' get_username_settings_query = '''SELECT `user_setting`.`name`, `user_setting`.`value` FROM `user_setting` JOIN `target` ON `target`.`id` = `user_setting`.`user_id` JOIN `target_type` ON `target_type`.`id` = `target`.`type_id` WHERE `target`.`name` = %s AND `target_type`.`name` = "user"''' update_username_settings_query = '''INSERT INTO `user_setting` (`user_id`, `name`, `value`) VALUES ( (SELECT `target`.`id` FROM `target` JOIN `target_type` ON `target_type`.`id` = `target`.`type_id` WHERE `target`.`name` = %(username)s AND `target_type`.`name` = "user"), %(name)s, %(value)s) ON DUPLICATE KEY UPDATE `value` = %(value)s''' check_application_ownership_query = '''SELECT 1 FROM `application_owner` JOIN `target` on `target`.`id` = `application_owner`.`user_id` WHERE `target`.`name` = :username AND `application_owner`.`application_id` = :application_id''' get_application_owners_query = '''SELECT `target`.`name` FROM `application_owner` JOIN `target` on `target`.`id` = `application_owner`.`user_id` WHERE `application_owner`.`application_id` = %s''' get_application_categories = ''' SELECT `notification_category`.`id`, `notification_category`.`name`, `notification_category`.`description`, `mode`.`name` AS mode FROM `notification_category` JOIN `mode` ON `notification_category`.`mode_id` = `mode`.`id` WHERE `application_id` = %s''' category_query = ''' SELECT `notification_category`.`id`, `notification_category`.`name`, `application`.`name` as application, `notification_category`.`description`, `mode`.`name` as mode FROM `notification_category` JOIN `application` ON `application`.`id` = `notification_category`.`application_id` JOIN `mode` ON `mode`.`id` = `notification_category`.`mode_id` ''' category_filters = { 'id': '`notification_category`.`id`', 'name': '`notification_category`.`name`', 'application': '`application`.`name`', 'mode': '`mode`.`name`' } category_filter_types = { 'id': str, 'name': str, 'application': str, 'mode': str } get_application_custom_sender_addresses = '''SELECT `mode`.`name` AS mode_name, `application_custom_sender_address`.`sender_address` AS address FROM `application_custom_sender_address` JOIN `mode` on `mode`.`id` = `application_custom_sender_address`.`mode_id` WHERE `application_custom_sender_address`.`application_id` = %s''' uuid4hex = re.compile('[0-9a-f]{32}\Z', re.I) def stream_incidents_with_context(cursor, title=False): for row in cursor: row['context'] = ujson.loads(row['context']) if title: title_variable_name = row.get('title_variable_name') if title_variable_name: row['title'] = row['context'].get(title_variable_name) else: row['title'] = None yield row def get_app_from_msg_id(session, msg_id): sql = '''SELECT `application`.`name` FROM `message` JOIN `application` on `application`.`id` = `message`.`application_id` WHERE `message`.`id` = :msg_id''' result = session.execute(sql, {'msg_id': msg_id}).scalar() if result: return result else: return None def is_valid_tracking_settings(t, k, tpl): if not t: if k or tpl: return False, 'Incomplete tracking setting' else: # no tracking setting return True, None if not (k and tpl): return False, 'Incomplete tracking setting' if not isinstance(tpl, dict) or not tpl: return False, 'Template must be a dictionary' if t == 'email': if '@' not in k: return False, 'Invalid email address' environment = SandboxedEnvironment() for app in tpl: if not tpl[app]: return False, 'No key for %s template' % app missed_keys = set(('email_subject', 'email_text')) - set(tpl[app]) if missed_keys: return False, 'Missing keys for %s template: %s' % (app, missed_keys) try: environment.from_string(tpl[app]['email_subject']) except jinja2.TemplateSyntaxError as e: return False, 'Invalid jinja syntax in subject: %s' % e try: environment.from_string(tpl[app]['email_text']) except jinja2.TemplateSyntaxError as e: return False, 'Invalid jinja syntax in body: %s' % e email_html = tpl[app].get('email_html') if email_html is not None: try: environment.from_string(email_html) except jinja2.TemplateSyntaxError as e: return False, 'Invalid jinja syntax in email html: %s' % e else: if t not in cache.modes: return False, 'Unknown tracking type: %s' % t environment = SandboxedEnvironment() for app in tpl: try: environment.from_string(tpl[app]['body']) except jinja2.TemplateSyntaxError as e: return False, 'Invalid jinja syntax in incident tracking text: %s' % e return True, None def gen_where_filter_clause(connection, filters, filter_types, kwargs): ''' How each where clauses are generated: 1. find out column part through filters[col], skipping nonexistent columns that might exist from invalid 'fields' parameters 2. find out operator part through operators[op] 3. escape value through connection.escape(filter_types.get(col, str)(value)) 4. (optional) transform escaped value through filter_escaped_value_transforms[col](value) ''' where = [] for key, values in kwargs.items(): col, _, op = key.partition('__') # Skip columns that don't exist if col not in filters: continue col_type = filter_types.get(col, str) # Format strings because Falcon splits on ',' but not on '%2C' # TODO: Get rid of this by setting request options on Falcon 1.1 if isinstance(values, str): values = values.split(',') for val in values: try: if op == 'in': if len(values) == 1: op = 'eq' val = col_type(values[0]) else: val = tuple([col_type(v) for v in values]) else: val = col_type(val) except (ValueError, TypeError): raise HTTPBadRequest('invalid argument type', '%s should be %s' % (col, col_type)) val = connection.escape(val) if col in filter_escaped_value_transforms: val = filter_escaped_value_transforms[col](val) where.append(operators[op] % (filters[col], val)) return where class HeaderMiddleware(object): def process_request(self, req, resp): resp.content_type = 'application/json' resp.set_headers([XFRAME, XCONTENTTYPEOPTIONS, XXSSPROTECTION]) class ReqBodyMiddleware(object): ''' Falcon's req object has a stream that we read to obtain the post body. However, we can only read this once, and we often need the post body twice (once for authentication and once in the handler method). To avoid this problem, we read the post body into the request context and access it from there. IMPORTANT NOTE: Because we use stream.read() here, all other uses of this method will return '', not the post body. ''' def process_request(self, req, resp): req.context['body'] = req.stream.read() class AuthMiddleware(object): def __init__(self, debug=False): if debug: self.process_resource = self.debug_auth def debug_auth(self, req, resp, resource, params): req.context['username'] = req.env.get('beaker.session', {}).get('user', None) method = req.method if resource.allow_read_no_auth and method == 'GET': return # If we're authenticated using beaker, don't validate app as if this is an # API call, but set 'app' to the internal iris user as some routes (test incident creation) # need it. if req.context['username']: req.context['app'] = cache.applications.get('iris') return # For the purpose of e2etests, allow setting username via header, rather than going # through beaker username_header = req.get_header('X-IRIS-USERNAME') if username_header: req.context['username'] = username_header return # If this is a frontend route, and we're not logged in, don't fall through to process as # an app. This will allow the ACLMiddleware to force the login page. if getattr(resource, 'frontend_route', False): return # Proceed with authenticating this route as a third party application try: # Ignore HMAC requirements for custom webhooks if req.env['PATH_INFO'].startswith('/v0/webhooks/'): app = req.get_param('application', required=True) else: app, client_digest = req.get_header('AUTHORIZATION', '')[5:].split(':', 1) if app not in cache.applications: logger.warning('Tried authenticating with nonexistent app: "%s"', app) raise HTTPUnauthorized('Authentication failure', 'Application not found', []) req.context['app'] = cache.applications[app] except TypeError: return def process_resource(self, req, resp, resource, params): # pragma: no cover req.context['username'] = req.env.get('beaker.session', {}).get('user', None) method = req.method if resource.allow_read_no_auth and method == 'GET': return # Ignore HMAC requirements for custom webhooks if req.env['PATH_INFO'].startswith('/v0/webhooks/'): app_name = req.get_param('application', required=True) app = cache.applications.get(app_name) if not app: raise HTTPUnauthorized('Authentication failure', 'Application not found', []) req.context['app'] = app # determine if we're correctly using an application key api_key = req.get_param('key', required=True) if not equals(api_key, str(app['key'])) or equals(api_key, str(app['secondary_key'])): logger.warning('Application key invalid') raise HTTPUnauthorized('Authentication failure', '', []) return # If we're authenticated using beaker, don't validate app as if this is an # API call, but set 'app' to the internal iris user as some routes (test incident creation) # need it. if req.context['username']: req.context['app'] = cache.applications.get('iris') return # If this is a frontend route, and we're not logged in, don't fall through to process as # an app either. This will allow the ACLMiddleware to force the login page. if getattr(resource, 'frontend_route', False): return # Proceed with authenticating this route as a third party application, and enforce # hmac for the entire request, and still allow the username-by-header functionality # if we're being hit from an instance of iris-frontend path = req.env['PATH_INFO'] qs = req.env['QUERY_STRING'] if qs: path = path + '?' + qs body = req.context['body'].decode('utf-8') auth = req.get_header('AUTHORIZATION') if auth and auth.startswith('hmac '): username_header = req.get_header('X-IRIS-USERNAME') try: app_name, client_digest = auth[5:].split(':', 1) app = cache.applications.get(app_name) if not app: logger.warning('Tried authenticating with nonexistent app: "%s"', app_name) raise HTTPUnauthorized('Authentication failure', '', []) if username_header and not app['allow_authenticating_users']: logger.warning('Unprivileged application %s tried authenticating %s', app['name'], username_header) raise HTTPUnauthorized('This application does not have the power to authenticate usernames', '', []) now = int(time.time()) windows = [ now // 5, (now // 5) - 1, now // 30, (now // 30) - 1, ] for api_key in (str(app['key']), str(app['secondary_key'])): for window in windows: # If username header is present, throw that into the hmac validation as well if username_header: text = '%s %s %s %s %s' % (window, method, path, body, username_header) else: text = '%s %s %s %s' % (window, method, path, body) HMAC = hmac.new(api_key.encode('utf-8'), text.encode('utf-8'), hashlib.sha512) digest = base64.urlsafe_b64encode(HMAC.digest()) if equals(client_digest.encode('utf-8'), digest): req.context['app'] = app if username_header: req.context['username'] = username_header return # No successful HMACs match, fail auth. if username_header: logger.warning('HMAC doesn\'t validate for app %s (passing username %s)', app['name'], username_header) else: logger.warning('HMAC doesn\'t validate for app %s; %s doesn\'t match "%s"', app['name'], client_digest, text) raise HTTPUnauthorized('Authentication failure', 'HMAC failed validation. Check API key/clock skew', []) except (ValueError, KeyError): logger.exception('Authentication failure') raise HTTPUnauthorized('Authentication failure', '', []) else: logger.warning('Request has malformed/missing HMAC authorization header') raise HTTPUnauthorized('Authentication failure', 'Malformed/missing HMAC authorization header', []) class ACLMiddleware(object): def __init__(self, debug): pass def process_resource(self, req, resp, resource, params): self.process_frontend_routes(req, resource) self.process_admin_acl(req, resource, params) self.load_user_settings(req) def process_frontend_routes(self, req, resource): if req.context['username']: # Logged in and looking at /login page? Redirect to home. if req.path == '/login': raise HTTPFound(ui.default_route) else: # If we're not logged in and this is a frontend route, we're only allowed # to view the login form if getattr(resource, 'frontend_route', False): if req.path != '/login': raise HTTPFound(ui.login_url(req)) def process_admin_acl(self, req, resource, params): req.context['is_admin'] = False # Quickly check the username in the path matches who's logged in enforce_user = getattr(resource, 'enforce_user', False) app = req.context.get('app') if not req.context['username']: # Check if we need to raise 401s when user must be enforced if enforce_user: # 401 if no username or app if not app: raise HTTPUnauthorized('Username must be specified for this action', '', []) # 401 if app exists but not allowed to authenticate as user elif not app.get('allow_authenticating_users'): raise HTTPUnauthorized('App must allow authentication as user for this action', '', []) # Otherwise, all clear return # Check if user is an admin connection = db.engine.raw_connection() cursor = connection.cursor() cursor.execute(check_username_admin_query, req.context['username']) result = cursor.fetchone() cursor.close() connection.close() if result: req.context['is_admin'] = bool(result[0]) if enforce_user and not req.context['is_admin']: path_username = params.get('username') if not equals(path_username, req.context['username']): raise HTTPUnauthorized('This user is not allowed to access this resource', '', []) def load_user_settings(self, req): req.context['user_settings'] = {} if not req.context['username']: return connection = db.engine.raw_connection() cursor = connection.cursor() cursor.execute(get_username_settings_query, req.context['username']) settings = dict(cursor) cursor.close() connection.close() req.context['user_settings'] = settings def acl_allowed(req, username): ''' Helper for checking ACLs for a given username when username is not included in params. Allow if the username matches the session user, if an app is using an API key and is allowed to authenticate users, or if the user is an admin. ''' return (req.context['username'] == username or req.context.get('app', {}).get('allow_authenticating_users') or req.context['is_admin']) class Plan(object): allow_read_no_auth = True def on_get(self, req, resp, plan_id): if plan_id.isdigit(): where = 'WHERE `plan`.`id` = %s' else: where = 'WHERE `plan`.`name` = %s AND `plan_active`.`plan_id` IS NOT NULL' query = single_plan_query + where connection = db.engine.raw_connection() cursor = connection.cursor(db.dict_cursor) cursor.execute(query, plan_id) plan = cursor.fetchone() if plan: step = 0 steps = [] cursor.execute(single_plan_query_steps, plan['id']) for notification in cursor: s = notification['step'] if s != step: l = [notification] steps.append(l) step = s else: l.append(notification) plan['steps'] = steps if plan['tracking_template']: plan['tracking_template'] = ujson.loads(plan['tracking_template']) resp.body = ujson.dumps(plan) connection.close() else: connection.close() raise HTTPNotFound() def on_post(self, req, resp, plan_id): post_body = ujson.loads(req.context['body']) try: active = int(post_body['active']) except KeyError: raise HTTPBadRequest('"active" field required') except ValueError: raise HTTPBadRequest('Invalid active field') with db.guarded_session() as session: if active: session.execute( '''INSERT INTO `plan_active` (`name`, `plan_id`) VALUES ((SELECT `name` FROM `plan` WHERE `id` = :plan_id), :plan_id) ON DUPLICATE KEY UPDATE `plan_id`=:plan_id''', {'plan_id': plan_id}) else: session.execute('DELETE FROM `plan_active` WHERE `plan_id`=:plan_id', {'plan_id': plan_id}) session.commit() session.close() resp.status = HTTP_200 resp.body = ujson.dumps(active) def on_delete(self, req, resp, plan_id): if plan_id.isdigit(): query = '''SELECT EXISTS(SELECT 1 FROM `plan` WHERE `id` = %s)''' plan_name = None else: query = '''SELECT EXISTS(SELECT 1 FROM `plan` WHERE `name` = %s)''' plan_name = plan_id connection = db.engine.raw_connection() cursor = connection.cursor() cursor.execute(query, plan_id) result = cursor.fetchone() if not result[0]: connection.close() raise HTTPBadRequest('No plan matched') if not plan_name: cursor.execute('SELECT `name` FROM `plan` WHERE `id` = %s', plan_id) result = cursor.fetchone() if not result: connection.close() raise HTTPBadRequest('Could not resolve this plan_id to name') plan_name = result[0] # Check if any quota is using these. cursor.execute('''SELECT `application`.`name` FROM `application_quota` JOIN `application` on `application`.`id` = `application_quota`.`application_id` WHERE `application_quota`.`plan_name` = %s''', plan_name) result = cursor.fetchone() if result: connection.close() raise HTTPBadRequest('Cannot delete this plan as the application %s is using it for quota' % result[0]) # Check if any incidents were made with these plan IDs. If they were, fail cursor.execute('SELECT COUNT(*) FROM `incident` WHERE `plan_id` IN (SELECT `id` FROM `plan` WHERE `name` = %s)', plan_name) result = cursor.fetchone() if result[0]: connection.close() raise HTTPBadRequest('Cannot delete this plan as %s incidents have been created using it' % result[0]) # Delete all steps try: cursor.execute('DELETE FROM `plan_notification` WHERE `plan_id` IN (SELECT `id` FROM `plan` WHERE `name` = %s)', plan_name) except IntegrityError: connection.close() raise HTTPBadRequest('Failed deleting plan steps') # Purge plan_active try: cursor.execute('DELETE FROM `plan_active` WHERE `name` = %s', plan_name) except IntegrityError: connection.close() raise HTTPBadRequest('Failed deleting plan steps') # Delete all matching plans try: cursor.execute('DELETE FROM `plan` WHERE `name` = %s', plan_name) except IntegrityError: connection.close() raise HTTPBadRequest('Failed deleting plans. It is likely still in use.') connection.commit() connection.close() resp.status = HTTP_200 resp.body = '[]' class Plans(object): allow_read_no_auth = True def on_get(self, req, resp): ''' Plan search endpoint. **Example request**: .. sourcecode:: http GET /v0/plans?name__contains=foo&active=1 HTTP/1.1 **Example response**: .. sourcecode:: http HTTP/1.1 200 OK Content-Type: application/json [ { "description": "This is plan foo", "threshold_count": 10, "creator": "user1", "created": 1478154275, "aggregation_reset": 300, "aggregation_window": 300, "threshold_window": 900, "tracking_type": null, "tracking_template": null, "tracking_key": null, "active": 1, "id": 123456, "name": "foo-sla0" } ] You can also search for plans that have specific targets in their steps by using the field 'target' **example request** GET /v0/plans?target=foo&active=1 HTTP/1.1 ''' query_limit = req.get_param_as_int('limit') req.params.pop('limit', None) fields = req.get_param_as_list('fields') fields = [f for f in fields if f in plan_columns] if fields else None req.params.pop('fields', None) if not fields: fields = plan_columns query = plan_query % ', '.join(plan_columns[f] for f in fields) connection = db.engine.raw_connection() cursor = connection.cursor(db.ss_dict_cursor) # search for plans which have steps that target a specific user for target_field in plan_target_fields: if req.params.get(target_field, None): target_query = plan_target_query where = [] where += gen_where_filter_clause(connection, plan_target_filters, plan_filter_types, req.params) if where: target_query = target_query + ' WHERE ' + ' AND '.join(where) query = query + ' JOIN (' + target_query + ') `plan_notification_subset` ON `plan_notification_subset`.`plan_id` = `plan`.`id`' break where = [] active = req.get_param_as_bool('active') req.params.pop('active', None) if active is not None: if active: where.append('`plan_active`.`plan_id` IS NOT NULL') else: where.append('`plan_active`.`plan_id` IS NULL') where += gen_where_filter_clause( connection, plan_filters, plan_filter_types, req.params) if where: query = query + ' WHERE ' + ' AND '.join(where) if query_limit is not None: query += ' ORDER BY `plan`.`created` DESC LIMIT %s' % query_limit cursor.execute(query) payload = ujson.dumps(cursor) connection.close() resp.status = HTTP_200 resp.body = payload def on_post(self, req, resp): ''' Plan create endpoint. Plans can either be static, defining role/targets in plan creation, or dynamic, leaving these fields blank during creation and determining role/targets at incident creation time. Static plan example: **Example request**: .. sourcecode:: http POST /v0/plans HTTP/1.1 { "aggregation_reset": 300, "aggregation_window": 300, "creator": "user-foo", "description": "this is a plan", "name": "plan-foo", "steps": [ [ { "priority": "urgent", "repeat": 0, "role": "user", "target": "demo", "template": "template-foo", "wait": 0 "optional": 0 } ] ], "threshold_count": 10, "threshold_window": 900 } **Example response**: .. sourcecode:: http HTTP/1.1 201 Created Content-Type: application/json 1 Aggregation: If a user receives more than $threshold_count messages from this plan via a given medium within $threshold_window seconds, group their messages for $aggregation_window seconds. After $aggregation_reset seconds without a message, aggregation stops. For a static plan, "steps" should be a list of arrays of JSON objects defining "priority", "repeat", "role", "target", "template", and "wait". Each array of objects represents a step in the plan, with the JSON objects representing the notifications delivered in that step. These notifications occur in parallel with one another within a given step. Descriptions of these parameters: :priority: Priority of messages sent by this step sub-part :repeat: Number of times this message will be repeated (e.g. repeat == 0 => 1 message) :role: Role of the target :target: Name of the target :template: Name of the template user to format this message :wait: Time to wait until sending a repeat message or moving on to the next plan step. Dynamic plan example: **Example request**: .. sourcecode:: http POST /v0/plans HTTP/1.1 { "aggregation_reset": 300, "aggregation_window": 300, "creator": "user-foo", "description": "this is a plan", "isValid": true, "name": "plan-foo", "steps": [ [ { "dynamic_index": 0, "priority": "urgent", "repeat": 0, "template": "template-foo", "wait": 0 "optional": 0 } ] ], "threshold_count": 10, "threshold_window": 900 } **Example response**: .. sourcecode:: http HTTP/1.1 201 Created Content-Type: application/json 1 For dynamic plans, step sub-parts define a "dynamic index" rather than a role/target combination. These indices must span a range from 0..n, where n is the largest dynamic index provided. At incident creation time, a mapping of dynamic index to role/target is provided to define the recipient of a message. See incidents POST endpoint for details. The total time of all plan steps can not exceed 24 hours. ''' plan_params = ujson.loads(req.context['body']) try: run_validation('plan', plan_params) except IrisValidationException as e: raise HTTPBadRequest('Validation error', str(e)) plan_name = plan_params['name'].strip() if not plan_name: raise HTTPBadRequest('Invalid plan', 'Empty plan name') if plan_name.isdigit(): raise HTTPBadRequest('Invalid plan', 'Plan name cannot be a number') if not plan_params.get('creator'): raise HTTPBadRequest('Invalid plan', 'Plan must specify a creator') if not acl_allowed(req, plan_params['creator']): raise HTTPUnauthorized('Invalid plan creator for authenticated app/user') tracking_key = plan_params.get('tracking_key') tracking_type = plan_params.get('tracking_type') tracking_template = plan_params.get('tracking_template') is_valid, err_msg = is_valid_tracking_settings(tracking_type, tracking_key, tracking_template) if not is_valid: raise HTTPBadRequest('Invalid tracking template', err_msg) if tracking_template: tracking_template = ujson.dumps(tracking_template) else: tracking_template = None # in case tracking_template is an empty dict now = datetime.datetime.utcnow() plan_dict = { 'creator': plan_params['creator'], 'name': plan_name, 'created': now, 'description': plan_params['description'], 'step_count': len(plan_params['steps']), 'threshold_window': plan_params['threshold_window'], 'threshold_count': plan_params['threshold_count'], 'aggregation_window': plan_params['aggregation_window'], 'aggregation_reset': plan_params['aggregation_reset'], 'tracking_key': tracking_key, 'tracking_type': tracking_type, 'tracking_template': tracking_template, } dynamic_indices = set() plan_length = 0 for steps in plan_params['steps']: longest_step = 0 for step in steps: if 'dynamic_index' in step: dynamic_indices.add(step['dynamic_index']) if (step.get('wait', 0) * step.get('count', 0)) > longest_step: longest_step = step.get('wait', 0) * step.get('count', 0) plan_length += longest_step if dynamic_indices != set(range(len(dynamic_indices))): raise HTTPBadRequest('Invalid plan', 'Dynamic target numbers must span 0..n without gaps') if plan_length > 86400: raise HTTPBadRequest('Invalid plan', 'Plan length exceeds the 24 hour maximum') with db.guarded_session() as session: plan_id = session.execute(insert_plan_query, plan_dict).lastrowid for index, steps in enumerate(plan_params['steps'], start=1): # A plan must have at least one non-optonal notification per step, if it doesn't reject the plan only_optional_flag = True for step in steps: dynamic = step.get('dynamic_index') is not None step['plan_id'] = plan_id step['step'] = index # for backwards copatibility check if optional is not defined and set it to 0 if it isn't step.setdefault('optional', 0) if step['optional'] == 0: only_optional_flag = False priority = cache.priorities.get(step['priority']) role = cache.target_roles.get(step.get('role')) if priority: step['priority_id'] = priority['id'] else: raise HTTPBadRequest('Invalid plan', 'Priority not found for step %s' % index) if not dynamic: if role: step['role_id'] = role else: raise HTTPBadRequest('Invalid plan', 'Role not found for step %s' % index) allowed_roles = {row[0] for row in session.execute(get_allowed_roles_query, step)} if not allowed_roles: raise HTTPBadRequest( 'Invalid plan', 'Target %s not found for step %s' % (step['target'], index)) if role not in allowed_roles: raise HTTPBadRequest( 'Invalid role', 'Role %s is not appropriate for target %s in step %s' % ( step['role'], step['target'], index)) try: if dynamic: session.execute(insert_dynamic_step_query, step) else: session.execute(insert_plan_step_query, step) except IntegrityError: raise HTTPBadRequest('Invalid plan', 'Invalid data for step %s' % index) if only_optional_flag: raise HTTPBadRequest('Invalid plan', 'You must have at least one non-optional notification per step. Step %s has none.' % index) session.execute('INSERT INTO `plan_active` (`name`, `plan_id`) ' 'VALUES (:name, :plan_id) ON DUPLICATE KEY UPDATE `plan_id`=:plan_id', {'name': plan_name, 'plan_id': plan_id}) session.commit() session.close() resp.status = HTTP_201 resp.body = ujson.dumps(plan_id) resp.set_header('Location', '/plans/%s' % plan_id) class Incidents(object): allow_read_no_auth = True def __init__(self, config): self.custom_incident_handler_dispatcher = CustomIncidentHandlerDispatcher(config) def on_get(self, req, resp): ''' Search for incidents. Returns a list of incidents matching specified parameters. Valid parameters are listed below: **Example request**: .. sourcecode:: http GET /v0/incidents?owner=jdoe&created__gt=1487466146&fields=id,owner HTTP/1.1 Host: example.com **Example response**: .. sourcecode:: http HTTP/1.1 200 OK Content-Type: application/json [ { "id": 123, "owner": "jdoe" }, { "id": 124, "owner": "jdoe" } ] :statuscode 400: Too much data requested; more filters are required :statuscode 200: Successful query *Allowed filter parameters:* - id: Incident id (int) - created: Incident creation time, in seconds since Unix epoch (int) - owner: Username of person who claimed the incident (string) - updated: Time when incident was last updated (e.g. claimed), in seconds since epoch (int) - active: Incident status. Incidents are active if unclaimed, and inactive if they are claimed or finished with their escalation plan (0 or 1 for inactive/active, respectively) - context: JSON string representing the incident context data - application: Application that created this incident (string) - plan: Escalation plan name (string) - plan_id: Escalation plan id (int) This endpoint also allows specification of a limit via another query parameter, which limits results to the N most recent incidents. Calls to this endpoint that do not specify either a limit or a filter will be rejected. To specify which incident attribute should be included in the output, the "fields" query parameter can be used. The fields parameter takes the value of a comma-separated list of attributes (e.g. id,owner), and the API will only include these incident fields in the output. If no "fields" value is specified, all fields will be returned. ''' fields = req.get_param_as_list('fields') req.params.pop('fields', None) if not fields: fields = incident_columns req.params.pop('fields', None) query_limit = req.get_param_as_int('limit') req.params.pop('limit', None) target = req.get_param_as_list('target') req.params.pop('target', None) query = incident_query % ', '.join(incident_columns[f] for f in fields if f in incident_columns) if target: query += 'JOIN `message` ON `message`.`incident_id` = `incident`.`id`' connection = db.engine.raw_connection() where = gen_where_filter_clause(connection, incident_filters, incident_filter_types, req.params) sql_values = [] if target: where.append('''`message`.`target_id` IN (SELECT `id` FROM `target` WHERE `target`.`name` IN %s )''') sql_values.append(tuple(target)) if not (where or query_limit): raise HTTPBadRequest('Incident query too broad, add filter or limit') if where: query = query + ' WHERE ' + ' AND '.join(where) if query_limit is not None: query += ' ORDER BY `incident`.`created` DESC, `incident`.`id` DESC LIMIT %s' % query_limit cursor = connection.cursor(db.ss_dict_cursor) cursor.execute(query, sql_values) if 'context' in fields: if 'title_variable_name' in fields: payload = ujson.dumps(stream_incidents_with_context(cursor, True)) else: payload = ujson.dumps(stream_incidents_with_context(cursor, False)) else: payload = ujson.dumps(cursor) connection.close() resp.status = HTTP_200 resp.body = payload def on_post(self, req, resp): ''' Create incidents. Id for the new incident will be returned. **Example request**: .. sourcecode:: http POST /v0/incidents HTTP/1.1 Content-Type: application/json { "plan": "test-plan", "context": {"number": 1, "str": "hello"} } **Example response**: .. sourcecode:: http HTTP/1.1 201 Created Content-Type: application/json 1 :statuscode 201: incident created :statuscode 400: invalid request :statuscode 404: plan not found :statuscode 401: application is not allowed to create incident for other application A request is considered invalid if: - plan name is missing - application is invalid - context json blob is longer than 65535 bytes - none of the templates used in the plan supports the given application To create an incident for a dynamic plan (one that defines dynamic targets), an additional `dynamic_targets` field must be passed along with the plan and context. Consider a dynamic plan defining two dynamic targets, indexed with 0 and 1. The `dynamic_targets` parameter should take the following form: .. sourcecode:: json [ { "role": "user", "target": "jdoe" }, { "role": "team", "target": "team-foo" } ] This will map target 0 to the user "jdoe", and target 1 to the team "team-foo". ''' incident_params = ujson.loads(req.context['body']) dynamic_targets = [] if 'plan' not in incident_params: raise HTTPBadRequest('missing plan name attribute') app = req.context['app'] with db.guarded_session() as session: plan_id = session.execute('SELECT `plan_id` FROM `plan_active` WHERE `name` = :plan', {'plan': incident_params['plan']}).scalar() if not plan_id: logger.warning('Plan "%s" not found.', incident_params['plan']) raise HTTPNotFound() num_dynamic = session.execute('SELECT COUNT(DISTINCT `dynamic_index`) FROM `plan_notification` ' 'WHERE `plan_id` = :plan_id', {'plan_id': plan_id}).scalar() # Support overriding the app which created this incident if 'application' in incident_params: if not req.context['app']['allow_other_app_incidents']: raise HTTPForbidden( ('This application %s does not allow creating incidents as ' 'other applications') % req.context['app']['name']) app = cache.applications.get(incident_params['application']) if not app: raise HTTPBadRequest('Invalid application') if num_dynamic > 0: target_list = incident_params.get('dynamic_targets', []) if num_dynamic != len(target_list): raise HTTPBadRequest('Invalid number of dynamic targets') for dynamic_target in target_list: target = session.execute('''SELECT `target_role`.`id` AS `role_id`, `target`.`id` AS `target_id` FROM `target` JOIN `target_role` ON `target_role`.`type_id` = `target`.`type_id` WHERE `target`.`name` = :target AND `target_role`.`name` = :role''', dynamic_target).fetchone() if target is None: raise HTTPBadRequest('Invalid incident', 'invalid role %s for target %s' % (dynamic_target['role'], dynamic_target['target'])) else: dynamic_targets.append(target) context = incident_params['context'] context_json_str = ujson.dumps({variable: context.get(variable) for variable in app['variables']}) if len(context_json_str) > 65535: raise HTTPBadRequest('Context too large. %d is larger than limit 65535' % len(context_json_str)) app_template_count = session.execute(''' SELECT EXISTS ( SELECT 1 FROM `plan_notification` JOIN `template` ON `template`.`name` = `plan_notification`.`template` JOIN `template_content` ON `template_content`.`template_id` = `template`.`id` WHERE `plan_notification`.`plan_id` = :plan_id AND `template_content`.`application_id` = :app_id ) ''', {'app_id': app['id'], 'plan_id': plan_id}).scalar() if not app_template_count: raise HTTPBadRequest('No plan template actions exist for this app') # To try to avoid deadlocks, split the inserts into their own session retries = 0 max_retries = 10 while True: with db.guarded_session() as session: retries += 1 try: data = { 'plan_id': plan_id, 'created': datetime.datetime.utcnow(), 'application_id': app['id'], 'context': context_json_str, 'current_step': 0, 'active': True, } incident_id = session.execute( '''INSERT INTO `incident` (`plan_id`, `created`, `context`, `current_step`, `active`, `application_id`) VALUES (:plan_id, :created, :context, 0, :active, :application_id)''', data).lastrowid for idx, target in enumerate(dynamic_targets): data = { 'incident_id': incident_id, 'target_id': target['target_id'], 'role_id': target['role_id'], 'index': idx session.commit() session.close() except (InternalError, OperationalError) as e: logger.error('Failed inserting incident. (Try %s/%s)', retries, max_retries) if retries < max_retries: sleep_jitter = random.randint(10, 30) / 100 sleep(sleep_jitter) except (InternalError, OperationalError) as e: logger.error('Failed inserting incident for plan %s. (Try %s/%s)', plan_id, retries, max_retries) if retries < max_retries: sleep_jitter = random.randint(10, 30) / 100 sleep(sleep_jitter) continue else: logger.exception('Breached incident insertion retry quota. Bailing on incident for plan %s', plan_id) raise HTTPInternalServerError('Failed creating incident') else: break resp.status = HTTP_201 resp.set_header('Location', '/incidents/%s' % incident_id) resp.body = ujson.dumps(incident_id) incident_data = { 'id': incident_id, 'plan': incident_params['plan'], 'created': int(time.time()), 'application': app['name'], 'context': context } # optional incident handler to do additional tasks after the incident has been created if self.custom_incident_handler_dispatcher.handlers: connection = db.engine.raw_connection() cursor = connection.cursor(db.dict_cursor) # get plan info query = single_plan_query + 'WHERE `plan`.`id` = %s' cursor.execute(query, plan_id) plan_details = cursor.fetchone() # get plan steps info step = 0 steps = [] cursor.execute(single_plan_query_steps, plan_id) highest_seen_priority_rank = -1 incident_data['priority'] = '' for notification in cursor: s = notification['step'] if s != step: l = [notification] steps.append(l) step = s else: l.append(notification) # calculate priority for this incident based on the most severe priority # across all notifications within the plan priority_name = notification['priority'] priority_rank = PRIORITY_PRECEDENCE_MAP.get(priority_name) if priority_rank is not None and priority_rank > highest_seen_priority_rank: highest_seen_priority_rank = priority_rank incident_data['priority'] = priority_name plan_details['steps'] = steps connection.close() incident_data["plan_details"] = plan_details self.custom_incident_handler_dispatcher.process_create(incident_data) class Incident(object): allow_read_no_auth = True def __init__(self, config): self.custom_incident_handler_dispatcher = CustomIncidentHandlerDispatcher(config) def on_get(self, req, resp, incident_id): ''' Get incident by ID. **Example request**: .. sourcecode:: http GET /v0/incident/1 HTTP/1.1 **Example response**: .. sourcecode:: http HTTP/1.1 200 OK Content-Type: application/json { "updated": 1492057026, "plan_id": 48271, "created": 1492055912, "application": "Alert manager", "steps": [ { "name": "alice", "created": 1492055953, "target_changed": 0, "mode_changed": 0, "priority": "urgent", "step": 1, "mode": "sms", "id": 25443689, "sent": 1492055957 } ], "plan": "test-plan", "context": {"number": 1, "str": "hello"}, "owner": "alice", "active": 0, "id": 1, "current_step": 1 } ''' connection = db.engine.raw_connection() cursor = connection.cursor(db.dict_cursor) try: cursor.execute(single_incident_query, int(incident_id)) except ValueError: raise HTTPBadRequest('Invalid incident id') incident = cursor.fetchone() if incident: cursor.execute(single_incident_query_steps, (auditlog.MODE_CHANGE, auditlog.TARGET_CHANGE, incident['id'])) incident['steps'] = cursor.fetchall() cursor.execute(single_incident_query_comments, incident['id']) incident['comments'] = cursor.fetchall() connection.close() incident['context'] = ujson.loads(incident['context']) payload = ujson.dumps(incident) else: connection.close() raise HTTPNotFound() resp.status = HTTP_200 resp.body = payload def on_post(self, req, resp, incident_id): ''' Claim incidents by incident id. Deactivates the incident and any associated messages, preventing further escalation. **Example request**: .. sourcecode:: http POST /v0/incidents/123 HTTP/1.1 Content-Type: application/json { "owner": "jdoe" } **Example response**: .. sourcecode:: http HTTP/1.1 200 OK Content-Type: application/json { "incident_id": "123", "owner": "jdoe", "active": false } ''' try: incident_id = int(incident_id) except ValueError: raise HTTPBadRequest('Invalid incident id') incident_params = ujson.loads(req.context['body']) try: owner = incident_params['owner'] except KeyError: raise HTTPBadRequest('"owner" field required') if owner is not None: if not acl_allowed(req, owner): raise HTTPUnauthorized('Invalid claimer for this app/user') connection = db.engine.raw_connection() cursor = connection.cursor() cursor.execute('''SELECT EXISTS( SELECT 1 FROM `incident` WHERE `incident`.`id` = %s)''', incident_id) if not cursor.fetchone()[0]: raise HTTPBadRequest('Invalid claim: no matching incident id') cursor.execute( '''SELECT EXISTS( SELECT 1 FROM `target` WHERE `target`.`name` = %s AND `type_id` = (SELECT `id` FROM `target_type` WHERE `name` = 'user') AND `target`.`active` = 1)''', owner) owner_valid = cursor.fetchone()[0] cursor.close() connection.close() if not owner_valid: raise HTTPBadRequest('Invalid claim: no matching owner') # claim_incident will close the session is_active = utils.claim_incident(incident_id, owner)[0] resp.status = HTTP_200 resp.body = ujson.dumps({'incident_id': incident_id, 'owner': owner, 'active': is_active}) # optional incident handler to do additional tasks after the incident has been claimed if self.custom_incident_handler_dispatcher.handlers: connection = db.engine.raw_connection() cursor = connection.cursor(db.dict_cursor) cursor.execute(single_incident_query, int(incident_id)) incident_data = cursor.fetchone() cursor.execute(single_incident_query_comments, incident_id) incident_data['comments'] = cursor.fetchall() incident_data['context'] = ujson.loads(incident_data['context']) cursor.close() connection.close() self.custom_incident_handler_dispatcher.process_claim(incident_data) class Resolved(object): allow_read_no_auth = False def __init__(self, config): self.custom_incident_handler_dispatcher = CustomIncidentHandlerDispatcher(config) def on_post(self, req, resp, incident_id): incident_params = ujson.loads(req.context['body']) if 'resolved' not in incident_params: raise HTTPBadRequest('resolved field required') resolved = incident_params.get('resolved') connection = db.engine.raw_connection() cursor = connection.cursor() cursor.execute('''SELECT EXISTS( SELECT 1 FROM `incident` WHERE `incident`.`id` = %s)''', incident_id) if not cursor.fetchone()[0]: raise HTTPBadRequest('Invalid claim: no matching incident id') cursor.close() connection.close() try: utils.resolve_incident(incident_id, resolved) except Exception as e: raise HTTPInternalServerError(description=e) resp.status = HTTP_200 resp.body = ujson.dumps({'incident_id': incident_id, 'resolved': resolved}) # optional incident handler to do additional tasks after the incident has been resolved if self.custom_incident_handler_dispatcher.handlers: connection = db.engine.raw_connection() cursor = connection.cursor(db.dict_cursor) cursor.execute(single_incident_query, int(incident_id)) incident_data = cursor.fetchone() cursor.execute(single_incident_query_comments, incident_id) incident_data['comments'] = cursor.fetchall() incident_data['context'] = ujson.loads(incident_data['context']) cursor.close() connection.close() self.custom_incident_handler_dispatcher.process_resolve(incident_data) class ClaimIncidents(object): allow_read_no_auth = False def __init__(self, config): self.custom_incident_handler_dispatcher = CustomIncidentHandlerDispatcher(config) def on_post(self, req, resp): params = ujson.loads(req.context['body']) try: owner = params['owner'] incident_ids = params['incident_ids'] except KeyError: raise HTTPBadRequest('Missing owner or incident_ids fields') if owner is not None: connection = db.engine.raw_connection() cursor = connection.cursor() for incident_id in incident_ids: cursor.execute('''SELECT EXISTS( SELECT 1 FROM `incident` WHERE `incident`.`id` = %s)''', incident_id) if not cursor.fetchone()[0]: raise HTTPBadRequest('Invalid claim: no matching incident id') cursor.execute( '''SELECT EXISTS( SELECT 1 FROM `target` WHERE `target`.`name` = %s AND `type_id` = (SELECT `id` FROM `target_type` WHERE `name` = 'user') AND `target`.`active` = 1)''', owner) owner_valid = cursor.fetchone()[0] cursor.close() connection.close() if not owner_valid: raise HTTPBadRequest('Invalid claim: no matching owner') claimed, unclaimed = utils.claim_bulk_incidents(incident_ids, owner) resp.status = HTTP_200 resp.body = ujson.dumps({'owner': owner, 'claimed': claimed, 'unclaimed': unclaimed}) # optional incident handler to do additional tasks after the incidents have been claimed if self.custom_incident_handler_dispatcher.handlers: connection = db.engine.raw_connection() cursor = connection.cursor(db.dict_cursor) for incident_id in incident_ids: cursor.execute(single_incident_query, int(incident_id)) incident_data = cursor.fetchone() cursor.execute(single_incident_query_comments, incident_id) incident_data['comments'] = cursor.fetchall() incident_data['context'] = ujson.loads(incident_data['context']) self.custom_incident_handler_dispatcher.process_claim(incident_data) cursor.close() connection.close() class Message(object): allow_read_no_auth = True def on_get(self, req, resp, message_id): ''' Get information for an iris message by id **Example request**: .. sourcecode:: http GET /v0/messages/{message_id} HTTP/1.1 **Example response**: .. sourcecode:: http HTTP/1.1 200 OK Content-Type: application/json { "body": "message body", "incident_id": 2590447, "target": "username", "created": 1490825218, "destination": "username@domain.com", "batch": null, "twilio_delivery_status": null, "priority": "low", "application": "app", "mode": "email", "active": 0, "generic_message_sent_status": 1, "id": 24807074, "sent": 1490825221, "subject": "message subject" } ''' connection = db.engine.raw_connection() cursor = connection.cursor(db.dict_cursor) cursor.execute(single_message_query, int(message_id)) message = cursor.fetchone() connection.close() if message: resp.body = ujson.dumps(message) else: raise HTTPNotFound() resp.status = HTTP_200 class MessageAuditLog(object): allow_read_no_auth = True def on_get(self, req, resp, message_id): ''' Get a message's log of changes **Example request**: .. sourcecode:: http GET /v0/messages/{message_id}/auditlog HTTP/1.1 **Example response**: .. sourcecode:: http HTTP/1.1 200 OK Content-Type: application/json [ { "old": "sms", "description": "Changing mode due to original mode failure", "date": 1489507284, "new": "email", "change_type": "mode-change", "id": 438 }, ] ''' connection = db.engine.raw_connection() cursor = connection.cursor(db.dict_cursor) cursor.execute(message_audit_log_query, int(message_id)) resp.body = ujson.dumps(cursor) connection.close() class Messages(object): allow_read_no_auth = True def on_get(self, req, resp): fields = req.get_param_as_list('fields') req.params.pop('fields', None) fields = [f for f in fields if f in message_columns] if fields else None if not fields: fields = message_columns query_limit = req.get_param_as_int('limit') req.params.pop('limit', None) connection = db.engine.raw_connection() escaped_params = { 'mode_change': connection.escape(auditlog.MODE_CHANGE), 'target_change': connection.escape(auditlog.TARGET_CHANGE) } query = message_query % ', '.join(message_columns[f] % escaped_params for f in fields) where = gen_where_filter_clause(connection, message_filters, message_filter_types, req.params) if not (where or query_limit): raise HTTPBadRequest('Message query too broad, add limit or filter') if where: query = query + ' WHERE ' + ' AND '.join(where) if query_limit is not None: query += ' ORDER BY `message`.`created` DESC LIMIT %s' % query_limit cursor = connection.cursor(db.ss_dict_cursor) cursor.execute(query) resp.body = ujson.dumps(cursor) connection.close() class Notifications(object): allow_read_no_auth = False required_attrs = frozenset(['target', 'role', 'subject']) def __init__(self, zk_hosts, default_sender_addr, timeout): self.default_sender_addr = default_sender_addr self.timeout = timeout if zk_hosts: from iris.coordinator.kazoo import Coordinator self.coordinator = Coordinator(zk_hosts=zk_hosts, hostname=None, port=None, join_cluster=False) else: logger.info('Not using ZK to get senders. Using host %s for leader instead.', default_sender_addr) self.coordinator = None def on_post(self, req, resp): ''' Create out of band notifications. Notification is ad-hoc message that's not tied to an incident. To achieve real-time delivery, notifications are not persisted in the Database. You can set the priority key to honor target's priority preference or set the mode key to force the message transport. You can use the role "literal_target" to prevent unrolling of targets and send messages directly to mailing lists or slack channels. Note that if you use this role you MUST specify the mode key but not the priority. This role will set the destination to the target value so make sure the target is a valid email address, slack channel, slack username, etc. Multi-recipient messages are supported for notifications explicitly specifying the "email" mode. To send a multi-recipient message, specify a list of objects defining "role" and "target" attributes. All roles, including "literal_target", are supported. These messages will be sent on a best-effort basis to as many targets as is possible. If any role:target pairs are found to be invalid, they will be skipped, and the message will be delivered to all other targets. Each object can also optionally define a "bcc" field, which will mark those targets as bcc if set to true. If no bcc attribute is defined for a target, the default value is false. **Example request**: .. sourcecode:: http POST /v0/notifications HTTP/1.1 Content-Type: application/json { "role": "secondary-oncall", "target": "test_oncall_team", "subject": "wake up", "body": "something is on fire", "priority": "high" } .. sourcecode:: http POST /v0/notifications HTTP/1.1 Content-Type: application/json { "role": "user", "target": "test_user", "subject": "wake up", "body": "something is on fire", "mode": "email" } .. sourcecode:: http POST /v0/notifications HTTP/1.1 Content-Type: application/json { "role": "literal_target", "target": "#slackchannel", "subject": "wake up", "body": "something is on fire", "mode": "slack" } .. sourcecode:: http POST /v0/notifications HTTP/1.1 Content-Type: application/json { "target_list": { { "role": "literal_target", "target": "list@example.com" }, { "role": "user", "target": "jdoe", "bcc": true } } "subject": "wake up", "body": "something is on fire", "mode": "email" } **Example response**: .. sourcecode:: http HTTP/1.1 200 OK Content-Type: application/json [] :statuscode 200: notification send request queued :statuscode 400: invalid request :statuscode 401: application is not allowed to create out of band notification A request is considered invalid if: - either target, subject or role is missing and target_list is not provided - target_list is provided, but no subject - target_list is malformed (not of the form [{"role": foo, "target": bar}]) - a priority is given for a target_list - both priority and mode are missing - invalid priority, mode - both template, body and email_html are missing - template, body and email_html is not a string - message queue request rejected by sender ''' message = ujson.loads(req.context['body']) msg_attrs = set(message) if not msg_attrs >= self.required_attrs: if 'target_list' not in msg_attrs: raise HTTPBadRequest('Missing required atrributes', ', '.join(self.required_attrs - msg_attrs)) else: if 'subject' not in msg_attrs: raise HTTPBadRequest('Missing required atrributes: subject') if not all(['role' in t and 'target' in t for t in message['target_list']]): raise HTTPBadRequest('Malformed target list') if 'target_list' in message: message['multi-recipient'] = True if 'mode' in message: mode_id = cache.modes.get(message['mode']) if not mode_id or message['mode'] != 'email': raise HTTPBadRequest('Invalid mode', message['mode']) message['mode_id'] = mode_id else: raise HTTPBadRequest('Contact mode required for target list') else: # If both priority and mode are passed in, priority overrides mode if 'priority' in message: priority = cache.priorities.get(message['priority']) if not priority: raise HTTPBadRequest('Invalid priority', message['priority']) message['priority_id'] = priority['id'] elif 'mode' in message: mode_id = cache.modes.get(message['mode']) if not mode_id: raise HTTPBadRequest('Invalid mode', message['mode']) message['mode_id'] = mode_id elif 'category' in message: app = req.context.get('app') if app is None: raise HTTPBadRequest('Invalid app specified for this notification category') category = app['categories'].get(message['category']) if category is None: # Add an additional DB check here in case our cache hasn't been refreshed yet. # This should cover the case when a user creates a category and immediately sends # a message for it, but shouldn't affect performance in the common case conn = db.engine.raw_connection() cursor = conn.cursor(db.dict_cursor) try: cursor.execute( '''SELECT `notification_category`.`id`, `notification_category`.`name`, `notification_category`.`mode_id`, `mode`.`name` AS mode FROM `notification_category` JOIN `mode` ON `notification_category`.`mode_id` = `mode`.`id` WHERE `application_id` = %s and `notification_category`.`name`= %s''', (app['id'], message['category'])) category = cursor.fetchone() except Exception: category = None finally: cursor.close() conn.close() # If we still don't have a category, raise 400 if category is None: raise HTTPBadRequest('No category named %s exists for this app' % message['category']) message['category_id'] = category['id'] message['category_mode_id'] = category['mode_id'] message['category_mode'] = category['mode'] else: raise HTTPBadRequest( 'Priority, mode, and category are missing, at least one is required') if message['role'] == 'literal_target': # target_literal requires that a mode be set and no priority be defined if 'mode' not in message: raise HTTPBadRequest('INVALID mode not set for literal_target role') if 'priority' in message or 'category' in message: raise HTTPBadRequest('INVALID role literal_target does not support priority or category') message['unexpanded'] = True # Avoid problems down the line if we have no way of creating the # message body, which happens if both body
{'addition_count': 2, 'commit_subject': 'add plan to log', 'deletion_count': 2, 'file_extension': '.py', 'lang': 'py', 'license': 'bsd-2-clause', 'repo_name': 'linkedin/iris'}