diff --git a/app/models/webhooks.py b/app/models/webhooks.py index 4f9325cb..2207f1c7 100644 --- a/app/models/webhooks.py +++ b/app/models/webhooks.py @@ -84,6 +84,15 @@ def revoke_webhook(id): return response +# function to return the status of the webhook (ie if it is active or not). If active, return True, else return False +def is_active(id): + response = client.get_item(TableName=table, Key={"id": {"S": id}}) + if "Item" in response: + return response["Item"]["active"]["BOOL"] + else: + return False + + def toggle_webhook(id): response = client.update_item( TableName=table, diff --git a/app/modules/sre/webhook_helper.py b/app/modules/sre/webhook_helper.py index d20ccf10..f4b6edeb 100644 --- a/app/modules/sre/webhook_helper.py +++ b/app/modules/sre/webhook_helper.py @@ -320,7 +320,7 @@ def toggle_webhook(ack, body, logger, client): channel = hook["channel"]["S"] webhooks.toggle_webhook(id) - message = f"Webhook {name} has been {'enabled' if hook['active']['BOOL'] else 'disabled'} by <@{username}>" + message = f"Webhook {name} has been {'disabled' if hook['active']['BOOL'] else 'enabled'} by <@{username}>" logger.info(message) client.chat_postMessage( channel=channel, diff --git a/app/server/server.py b/app/server/server.py index 4389d4f6..ea33bafd 100644 --- a/app/server/server.py +++ b/app/server/server.py @@ -186,102 +186,106 @@ def geolocate(ip): def handle_webhook(id: str, payload: WebhookPayload | str, request: Request): webhook = webhooks.get_webhook(id) if webhook: - webhooks.increment_invocation_count(id) - - if isinstance(payload, str): + # if the webhook is active, then send forward the response to the webhook + if webhooks.is_active(id): + webhooks.increment_invocation_count(id) + if isinstance(payload, str): + try: + payload = AwsSnsPayload.parse_raw(payload) + sns_message_validator.validate_message(message=payload.dict()) + except InvalidMessageTypeException as e: + logging.error(e) + log_ops_message( + request.state.bot.client, + f"Invalid message type ```{payload.Type}``` in message: ```{payload}```", + ) + raise HTTPException( + status_code=500, + detail=f"Failed to parse AWS event message due to {e.__class__.__qualname__}: {e}", + ) + except InvalidSignatureVersionException as e: + logging.error(e) + log_ops_message( + request.state.bot.client, + f"Unexpected signature version ```{payload.SignatureVersion}``` in message: ```{payload}```", + ) + raise HTTPException( + status_code=500, + detail=f"Failed to parse AWS event message due to {e.__class__.__qualname__}: {e}", + ) + except SignatureVerificationFailureException as e: + logging.error(e) + log_ops_message( + request.state.bot.client, + f"Failed to verify signature ```{payload.Signature}``` in message: ```{payload}```", + ) + raise HTTPException( + status_code=500, + detail=f"Failed to parse AWS event message due to {e.__class__.__qualname__}: {e}", + ) + except InvalidCertURLException as e: + logging.error(e) + log_ops_message( + request.state.bot.client, + f"Invalid certificate URL ```{payload.SigningCertURL}``` in message: ```{payload}```", + ) + raise HTTPException( + status_code=500, + detail=f"Failed to parse AWS event message due to {e.__class__.__qualname__}: {e}", + ) + except Exception as e: + logging.error(e) + log_ops_message( + request.state.bot.client, + f"Error parsing AWS event due to {e.__class__.__qualname__}: ```{payload}```", + ) + raise HTTPException( + status_code=500, + detail=f"Failed to parse AWS event message due to {e.__class__.__qualname__}: {e}", + ) + if payload.Type == "SubscriptionConfirmation": + requests.get(payload.SubscribeURL, timeout=60) + logging.info(f"Subscribed webhook {id} to topic {payload.TopicArn}") + log_ops_message( + request.state.bot.client, + f"Subscribed webhook {id} to topic {payload.TopicArn}", + ) + return {"ok": True} + + if payload.Type == "UnsubscribeConfirmation": + log_ops_message( + request.state.bot.client, + f"{payload.TopicArn} unsubscribed from webhook {id}", + ) + return {"ok": True} + + if payload.Type == "Notification": + blocks = aws.parse(payload, request.state.bot.client) + # if we have an empty message, log that we have an empty + # message and return without posting to slack + if not blocks: + logging.info("No blocks to post, returning") + return + payload = WebhookPayload(blocks=blocks) + payload.channel = webhook["channel"]["S"] + payload = append_incident_buttons(payload, id) try: - payload = AwsSnsPayload.parse_raw(payload) - sns_message_validator.validate_message(message=payload.dict()) - except InvalidMessageTypeException as e: - logging.error(e) - log_ops_message( - request.state.bot.client, - f"Invalid message type ```{payload.Type}``` in message: ```{payload}```", - ) - raise HTTPException( - status_code=500, - detail=f"Failed to parse AWS event message due to {e.__class__.__qualname__}: {e}", - ) - except InvalidSignatureVersionException as e: - logging.error(e) - log_ops_message( - request.state.bot.client, - f"Unexpected signature version ```{payload.SignatureVersion}``` in message: ```{payload}```", - ) - raise HTTPException( - status_code=500, - detail=f"Failed to parse AWS event message due to {e.__class__.__qualname__}: {e}", - ) - except SignatureVerificationFailureException as e: - logging.error(e) - log_ops_message( - request.state.bot.client, - f"Failed to verify signature ```{payload.Signature}``` in message: ```{payload}```", - ) - raise HTTPException( - status_code=500, - detail=f"Failed to parse AWS event message due to {e.__class__.__qualname__}: {e}", - ) - except InvalidCertURLException as e: - logging.error(e) - log_ops_message( - request.state.bot.client, - f"Invalid certificate URL ```{payload.SigningCertURL}``` in message: ```{payload}```", - ) - raise HTTPException( - status_code=500, - detail=f"Failed to parse AWS event message due to {e.__class__.__qualname__}: {e}", + message = json.loads(payload.json(exclude_none=True)) + request.state.bot.client.api_call("chat.postMessage", json=message) + log_to_sentinel( + "webhook_sent", {"webhook": webhook, "payload": payload.dict()} ) + return {"ok": True} except Exception as e: logging.error(e) + body = payload.json(exclude_none=True) log_ops_message( - request.state.bot.client, - f"Error parsing AWS event due to {e.__class__.__qualname__}: ```{payload}```", - ) - raise HTTPException( - status_code=500, - detail=f"Failed to parse AWS event message due to {e.__class__.__qualname__}: {e}", - ) - if payload.Type == "SubscriptionConfirmation": - requests.get(payload.SubscribeURL, timeout=60) - logging.info(f"Subscribed webhook {id} to topic {payload.TopicArn}") - log_ops_message( - request.state.bot.client, - f"Subscribed webhook {id} to topic {payload.TopicArn}", + request.state.bot.client, f"Error posting message: ```{body}```" ) - return {"ok": True} - - if payload.Type == "UnsubscribeConfirmation": - log_ops_message( - request.state.bot.client, - f"{payload.TopicArn} unsubscribed from webhook {id}", - ) - return {"ok": True} - - if payload.Type == "Notification": - blocks = aws.parse(payload, request.state.bot.client) - # if we have an empty message, log that we have an empty - # message and return without posting to slack - if not blocks: - logging.info("No blocks to post, returning") - return - payload = WebhookPayload(blocks=blocks) - payload.channel = webhook["channel"]["S"] - payload = append_incident_buttons(payload, id) - try: - message = json.loads(payload.json(exclude_none=True)) - request.state.bot.client.api_call("chat.postMessage", json=message) - log_to_sentinel( - "webhook_sent", {"webhook": webhook, "payload": payload.dict()} - ) - return {"ok": True} - except Exception as e: - logging.error(e) - body = payload.json(exclude_none=True) - log_ops_message( - request.state.bot.client, f"Error posting message: ```{body}```" - ) - raise HTTPException(status_code=500, detail="Failed to send message") + raise HTTPException(status_code=500, detail="Failed to send message") + else: + logging.info(f"Webhook id {id} is not active") + raise HTTPException(status_code=404, detail="Webhook not active") else: raise HTTPException(status_code=404, detail="Webhook not found") diff --git a/app/tests/models/test_webhooks.py b/app/tests/models/test_webhooks.py index fa5081e8..3106b997 100644 --- a/app/tests/models/test_webhooks.py +++ b/app/tests/models/test_webhooks.py @@ -165,6 +165,46 @@ def test_revoke_webhook(client_mock): ) +@patch("models.webhooks.client") +def test_is_active_returns_true(client_mock): + client_mock.get_item.return_value = { + "Item": { + "id": {"S": "test_id"}, + "channel": {"S": "test_channel"}, + "name": {"S": "test_name"}, + "created_at": {"S": "test_created_at"}, + "active": {"BOOL": True}, + "user_id": {"S": "test_user_id"}, + "invocation_count": {"N": "0"}, + "acknowledged_count": {"N": "0"}, + } + } + assert webhooks.is_active("test_id") is True + + +@patch("models.webhooks.client") +def test_is_active_returns_false(client_mock): + client_mock.get_item.return_value = { + "Item": { + "id": {"S": "test_id"}, + "channel": {"S": "test_channel"}, + "name": {"S": "test_name"}, + "created_at": {"S": "test_created_at"}, + "active": {"BOOL": False}, + "user_id": {"S": "test_user_id"}, + "invocation_count": {"N": "0"}, + "acknowledged_count": {"N": "0"}, + } + } + assert webhooks.is_active("test_id") is False + + +@patch("models.webhooks.client") +def test_is_active_not_found(client_mock): + client_mock.get_item.return_value = {} + assert webhooks.is_active("test_id") is False + + @patch("models.webhooks.client") @patch("models.webhooks.get_webhook") def test_toggle_webhook(get_webhook_mock, client_mock): diff --git a/app/tests/modules/sre/test_webhook_helper.py b/app/tests/modules/sre/test_webhook_helper.py index 3c8f012c..623fc5e7 100644 --- a/app/tests/modules/sre/test_webhook_helper.py +++ b/app/tests/modules/sre/test_webhook_helper.py @@ -505,11 +505,11 @@ def test_toggle_webhook(list_all_webhooks_mock, toggle_webhook_mock, get_webhook webhook_helper.toggle_webhook(ack, body, logger, client) ack.assert_called() toggle_webhook_mock.assert_called_with("id") - logger.info.assert_called_with("Webhook name has been enabled by <@username>") + logger.info.assert_called_with("Webhook name has been disabled by <@username>") client.chat_postMessage.assert_called_with( channel="channel", user="user_id", - text="Webhook name has been enabled by <@username>", + text="Webhook name has been disabled by <@username>", ) list_all_webhooks_mock.assert_called_with( client, body, 0, webhook_helper.MAX_BLOCK_SIZE, "all", update=True diff --git a/app/tests/server/test_server.py b/app/tests/server/test_server.py index c666da05..420435af 100644 --- a/app/tests/server/test_server.py +++ b/app/tests/server/test_server.py @@ -35,15 +35,18 @@ def test_geolocate_failure(mock_geolocate): @patch("server.server.append_incident_buttons") @patch("server.server.webhooks.get_webhook") +@patch("server.server.webhooks.is_active") @patch("server.server.webhooks.increment_invocation_count") @patch("server.server.log_to_sentinel") def test_handle_webhook_found( _log_to_sentinel_mock, increment_invocation_count_mock, + is_active_mock, get_webhook_mock, append_incident_buttons_mock, ): get_webhook_mock.return_value = {"channel": {"S": "channel"}} + is_active_mock.return_value = True payload = {"channel": "channel"} append_incident_buttons_mock.return_value.json.return_value = "[]" response = client.post("/hook/id", json=payload) @@ -54,13 +57,42 @@ def test_handle_webhook_found( assert append_incident_buttons_mock.call_count == 1 +@patch("server.server.append_incident_buttons") @patch("server.server.webhooks.get_webhook") +@patch("server.server.webhooks.is_active") +@patch("server.server.webhooks.increment_invocation_count") +@patch("server.server.log_to_sentinel") +def test_handle_webhook_disabled( + _log_to_sentinel_mock, + increment_invocation_count_mock, + is_active_mock, + get_webhook_mock, + append_incident_buttons_mock, +): + is_active_mock.return_value = False + get_webhook_mock.return_value = {"channel": {"S": "channel"}} + payload = {"channel": "channel"} + append_incident_buttons_mock.return_value.json.return_value = "[]" + response = client.post("/hook/id", json=payload) + assert response.status_code == 404 + assert response.json() == {"detail": "Webhook not active"} + assert get_webhook_mock.call_count == 1 + assert increment_invocation_count_mock.call_count == 0 + assert append_incident_buttons_mock.call_count == 0 + + +@patch("server.server.webhooks.get_webhook") +@patch("server.server.webhooks.is_active") @patch("server.server.webhooks.increment_invocation_count") @patch("server.server.log_ops_message") def test_handle_webhook_with_invalid_aws_json_payload( - _log_ops_message_mock, _increment_invocation_count_mock, get_webhook_mock + _log_ops_message_mock, + _increment_invocation_count_mock, + is_active_mock, + get_webhook_mock, ): get_webhook_mock.return_value = {"channel": {"S": "channel"}} + is_active_mock.return_value = True payload = "not a json payload" response = client.post("/hook/id", json=payload) assert response.status_code == 500 @@ -68,12 +100,17 @@ def test_handle_webhook_with_invalid_aws_json_payload( @patch("server.server.webhooks.get_webhook") +@patch("server.server.webhooks.is_active") @patch("server.server.webhooks.increment_invocation_count") @patch("server.server.log_ops_message") def test_handle_webhook_with_bad_aws_signature( - _log_ops_message_mock, _increment_invocation_count_mock, get_webhook_mock + _log_ops_message_mock, + _increment_invocation_count_mock, + is_active_mock, + get_webhook_mock, ): get_webhook_mock.return_value = {"channel": {"S": "channel"}} + is_active_mock.return_value = True payload = '{"Type": "foo"}' response = client.post("/hook/id", json=payload) assert response.status_code == 500 @@ -81,12 +118,17 @@ def test_handle_webhook_with_bad_aws_signature( @patch("server.server.webhooks.get_webhook") +@patch("server.server.webhooks.is_active") @patch("server.server.webhooks.increment_invocation_count") @patch("server.server.log_ops_message") def test_handle_webhook_with_bad_aws_message_type( - _log_ops_message_mock, _increment_invocation_count_mock, get_webhook_mock + _log_ops_message_mock, + _increment_invocation_count_mock, + is_active_mock, + get_webhook_mock, ): get_webhook_mock.return_value = {"channel": {"S": "channel"}} + is_active_mock.return_value = True payload = '{"Type": "foo"}' response = client.post("/hook/id", json=payload) assert response.status_code == 500 @@ -96,12 +138,17 @@ def test_handle_webhook_with_bad_aws_message_type( @patch("server.server.webhooks.get_webhook") +@patch("server.server.webhooks.is_active") @patch("server.server.webhooks.increment_invocation_count") @patch("server.server.log_ops_message") def test_handle_webhook_with_bad_aws_invalid_cert_version( - _log_ops_message_mock, _increment_invocation_count_mock, get_webhook_mock + _log_ops_message_mock, + _increment_invocation_count_mock, + is_active_mock, + get_webhook_mock, ): get_webhook_mock.return_value = {"channel": {"S": "channel"}} + is_active_mock.return_value = True payload = ( '{"Type": "Notification", "SignatureVersion": "foo", "SigningCertURL": "foo"}' ) @@ -113,12 +160,17 @@ def test_handle_webhook_with_bad_aws_invalid_cert_version( @patch("server.server.webhooks.get_webhook") +@patch("server.server.webhooks.is_active") @patch("server.server.webhooks.increment_invocation_count") @patch("server.server.log_ops_message") def test_handle_webhook_with_bad_aws_invalid_signature_version( - _log_ops_message_mock, _increment_invocation_count_mock, get_webhook_mock + _log_ops_message_mock, + _increment_invocation_count_mock, + is_active_mock, + get_webhook_mock, ): get_webhook_mock.return_value = {"channel": {"S": "channel"}} + is_active_mock.return_value = True payload = '{"Type":"Notification", "SigningCertURL":"https://foo.pem", "SignatureVersion":"1"}' response = client.post("/hook/id", json=payload) @@ -129,6 +181,7 @@ def test_handle_webhook_with_bad_aws_invalid_signature_version( @patch("server.server.webhooks.get_webhook") +@patch("server.server.webhooks.is_active") @patch("server.server.webhooks.increment_invocation_count") @patch("server.server.log_ops_message") @patch("server.server.sns_message_validator.validate_message") @@ -138,10 +191,12 @@ def test_handle_webhook_with_SubscriptionConfirmation_payload( validate_message_mock, log_ops_message_mock, _increment_invocation_count_mock, + is_active_mock, get_webhook_mock, ): validate_message_mock.return_value = True get_webhook_mock.return_value = {"channel": {"S": "channel"}} + is_active_mock.return_value = True payload = '{"Type": "SubscriptionConfirmation", "SubscribeURL": "SubscribeURL", "TopicArn": "TopicArn"}' response = client.post("/hook/id", json=payload) assert response.status_code == 200 @@ -150,6 +205,7 @@ def test_handle_webhook_with_SubscriptionConfirmation_payload( @patch("server.server.webhooks.get_webhook") +@patch("server.server.webhooks.is_active") @patch("server.server.webhooks.increment_invocation_count") @patch("server.server.sns_message_validator.validate_message") @patch("server.server.log_ops_message") @@ -157,10 +213,12 @@ def test_handle_webhook_with_UnsubscribeConfirmation_payload( log_ops_message_mock, validate_message_mock, _increment_invocation_count_mock, + is_active_mock, get_webhook_mock, ): validate_message_mock.return_value = True get_webhook_mock.return_value = {"channel": {"S": "channel"}} + is_active_mock.return_value = True payload = '{"Type": "UnsubscribeConfirmation"}' response = client.post("/hook/id", json=payload) assert response.status_code == 200 @@ -169,6 +227,7 @@ def test_handle_webhook_with_UnsubscribeConfirmation_payload( @patch("server.server.webhooks.get_webhook") +@patch("server.server.webhooks.is_active") @patch("server.server.webhooks.increment_invocation_count") @patch("server.server.sns_message_validator.validate_message") @patch("server.server.aws.parse") @@ -178,11 +237,13 @@ def test_handle_webhook_with_Notification_payload( parse_mock, validate_message_mock, _increment_invocation_count_mock, + is_active_mock, get_webhook_mock, ): validate_message_mock.return_value = True parse_mock.return_value = ["foo", "bar"] get_webhook_mock.return_value = {"channel": {"S": "channel"}} + is_active_mock.return_value = True payload = '{"Type": "Notification"}' response = client.post("/hook/id", json=payload) assert response.status_code == 200 @@ -191,15 +252,18 @@ def test_handle_webhook_with_Notification_payload( @patch("server.server.append_incident_buttons") @patch("server.server.webhooks.get_webhook") +@patch("server.server.webhooks.is_active") @patch("server.server.webhooks.increment_invocation_count") @patch("server.server.log_ops_message") def test_handle_webhook_found_but_exception( log_ops_message_mock, increment_invocation_count_mock, + is_active_mock, get_webhook_mock, append_incident_buttons_mock, ): get_webhook_mock.return_value = {"channel": {"S": "channel"}} + is_active_mock.return_value = True payload = MagicMock() append_incident_buttons_mock.return_value.json.return_value = "[]" request = MagicMock() @@ -210,6 +274,7 @@ def test_handle_webhook_found_but_exception( @patch("server.server.webhooks.get_webhook") +@patch("server.server.webhooks.is_active") @patch("server.server.webhooks.increment_invocation_count") @patch("server.server.sns_message_validator.validate_message") @patch("server.server.aws.parse") @@ -219,12 +284,14 @@ def test_handle_webhook_with_empty_text_for_payload( parse_mock, validate_message_mock, _increment_invocation_count_mock, + is_active_mock, get_webhook_mock, ): # Test that we don't post to slack if we have an empty message validate_message_mock.return_value = True parse_mock.return_value = [] get_webhook_mock.return_value = {"channel": {"S": "channel"}} + is_active_mock.return_value = True payload = '{"Type": "Notification", "Message": "{}"}' response = client.post("/hook/id", json=payload) assert response.status_code == 200