Skip to content
New issue

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

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

Already on GitHub? Sign in to your account

Creates a new plugin for Duo that provides on-demand MFA across Dispatch #3035

Merged
merged 12 commits into from
Mar 1, 2023
1 change: 1 addition & 0 deletions requirements-base.in
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ bcrypt
cachetools
chardet
click
duo-client
email-validator
emails
fastapi
Expand Down
5 changes: 4 additions & 1 deletion requirements-base.txt
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
#
# This file is autogenerated by pip-compile with Python 3.9
# This file is autogenerated by pip-compile with Python 3.11
# by the following command:
#
# pip-compile requirements-base.in
Expand Down Expand Up @@ -99,6 +99,8 @@ deprecated==1.2.13
# via atlassian-python-api
dnspython==2.2.1
# via email-validator
duo-client==4.6.1
# via -r requirements-base.in
ecdsa==0.18.0
# via python-jose
email-validator==1.3.1
Expand Down Expand Up @@ -349,6 +351,7 @@ sh==2.0.2
six==1.16.0
# via
# atlassian-python-api
# duo-client
# ecdsa
# google-auth
# google-auth-httplib2
Expand Down
1 change: 1 addition & 0 deletions setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -409,6 +409,7 @@ def run(self):
"dispatch_pkce_auth = dispatch.plugins.dispatch_core.plugin:PKCEAuthProviderPlugin",
"dispatch_header_auth = dispatch.plugins.dispatch_core.plugin:HeaderAuthProviderPlugin",
"dispatch_ticket = dispatch.plugins.dispatch_core.plugin:DispatchTicketPlugin",
"duo_mfa = dispatch.plugins.dispatch_duo.plugin:DuoMfaPlugin",
"generic_workflow = dispatch.plugins.generic_workflow.plugin:GenericWorkflowPlugin",
"github_monitor = dispatch.plugins.dispatch_github.plugin:GithubMonitorPlugin",
"google_calendar_conference = dispatch.plugins.dispatch_google.calendar.plugin:GoogleCalendarConferencePlugin",
Expand Down
1 change: 1 addition & 0 deletions src/dispatch/plugins/bases/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
from .document import DocumentPlugin # noqa
from .document_resolver import DocumentResolverPlugin # noqa
from .email import EmailPlugin # noqa
from .mfa import MultiFactorAuthenticationPlugin # noqa
from .oncall import OncallPlugin # noqa
from .participant import ParticipantPlugin # noqa
from .participant_group import ParticipantGroupPlugin # noqa
Expand Down
15 changes: 15 additions & 0 deletions src/dispatch/plugins/bases/mfa.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
"""
.. module: dispatch.plugins.bases.mfa
:platform: Unix
:copyright: (c) 2023 by Netflix Inc., see AUTHORS for more
:license: Apache, see LICENSE for more details.
.. moduleauthor:: Will Sheldon <[email protected]>
"""
from dispatch.plugins.base import Plugin


class MultiFactorAuthenticationPlugin(Plugin):
type = "mfa"

def send_push_notification(self, items, **kwargs):
raise NotImplementedError
1 change: 1 addition & 0 deletions src/dispatch/plugins/dispatch_duo/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
from ._version import __version__ # noqa
1 change: 1 addition & 0 deletions src/dispatch/plugins/dispatch_duo/_version.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
__version__ = "0.1.0"
18 changes: 18 additions & 0 deletions src/dispatch/plugins/dispatch_duo/config.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
from pydantic import Field, SecretStr
from dispatch.config import BaseConfigurationModel


class DuoConfiguration(BaseConfigurationModel):
"""Duo configuration description."""

integration_key: SecretStr = Field(
title="Integration Key", description="Admin API integration key ('DI...'):"
)
integration_secret_key: SecretStr = Field(
title="Integration Secret Key",
description="Secret token used in conjunction with integration key.",
)
host: str = Field(
title="API Hostname",
description="API hostname ('api-....duosecurity.com'): ",
)
83 changes: 83 additions & 0 deletions src/dispatch/plugins/dispatch_duo/plugin.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
"""
.. module: dispatch.plugins.dispatch_duo.plugin
:platform: Unix
:copyright: (c) 2023 by Netflix Inc., see AUTHORS for more
:license: Apache, see LICENSE for more details.
.. moduleauthor:: Will Sheldon <[email protected]>
"""
import logging
from typing import NewType

from dispatch.decorators import apply, counter, timer
from dispatch.plugins.bases import MultiFactorAuthenticationPlugin
from dispatch.plugins.dispatch_duo import service as duo_service
from dispatch.plugins.dispatch_duo.config import DuoConfiguration
from . import __version__

log = logging.getLogger(__name__)


DuoAuthResponse = NewType(
"DuoAuthResponse",
dict[
str,
dict[str, str] | str,
],
)


@apply(timer, exclude=["__init__"])
@apply(counter, exclude=["__init__"])
class DuoMfaPlugin(MultiFactorAuthenticationPlugin):
title = "Duo Plugin - Multi Factor Authentication"
slug = "duo-mfa"
description = "Uses Duo to validate user actions with multi-factor authentication."
version = __version__

author = "Netflix"
author_url = "https://github.com/netflix/dispatch.git"

def __init__(self):
self.configuration_schema = DuoConfiguration

def send_push_notification(
self,
username: str,
type: str,
device: str = "auto",
) -> DuoAuthResponse:
"""Create a new push notification for authentication.

This function sends a push notification to a Duo-enabled device for multi-factor authentication.

Args:
username (str): The unique identifier for the user, commonly specified by your application during user
creation (e.g. [email protected]). This value may also represent a username alias assigned to a user (e.g. wshel).

type (str): A string that is displayed in the Duo Mobile app push notification and UI. The notification text
changes to "Verify request" and shows your customized string followed by a colon and the application's name,
and the request details screen also shows your customized string and the application's name.

device (str, optional): The ID of the device. This device must have the "push" capability. Defaults to "auto"
to use the first of the user's devices with the "push" capability.

Returns:
DuoAuthResponse: The response from the Duo API. A successful response would appear as:
{"response": {"result": "allow", "status": "allow", "status_msg": "Success. Logging you in..."}, "stat": "OK"}

Example:
>>> plugin = DuoMfaPlugin()
>>> result = plugin.send_push_notification(username='[email protected]', type='Login Request')
>>> result
{'response': {'result': 'allow', 'status': 'allow', 'status_msg': 'Success. Logging you in...'}, 'stat': 'OK'}

Notes:
For more information, see https://duo.com/docs/authapi#/auth.
wssheldon marked this conversation as resolved.
Show resolved Hide resolved
"""
duo_client = duo_service.create_duo_auth_client(self.configuration)
return duo_client.auth(
factor="push",
username=username,
device=device,
type=type,
)
13 changes: 13 additions & 0 deletions src/dispatch/plugins/dispatch_duo/service.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import duo_client
from duo_client.auth import Auth

from dispatch.plugins.dispatch_duo.config import DuoConfiguration


def create_duo_auth_client(config: DuoConfiguration) -> Auth:
"""Creates a Duo Auth API client."""
return duo_client.Auth(
ikey=config.integration_key.get_secret_value(),
skey=config.integration_secret_key.get_secret_value(),
host=config.host,
)
5 changes: 5 additions & 0 deletions src/dispatch/plugins/dispatch_slack/case/enums.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@


class CaseNotificationActions(DispatchEnum):
snooze = "case-notification-snooze"
escalate = "case-notification-escalate"
resolve = "case-notification-resolve"
reopen = "case-notification-reopen"
Expand All @@ -14,6 +15,10 @@ class CaseEditActions(DispatchEnum):
submit = "case-notification-edit-submit"


class CaseSnoozeActions(DispatchEnum):
submit = "case-notification-snooze-submit"


class CaseResolveActions(DispatchEnum):
submit = "case-notification-resolve-submit"

Expand Down
87 changes: 87 additions & 0 deletions src/dispatch/plugins/dispatch_slack/case/interactive.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
from dispatch.case.models import CaseCreate, CaseUpdate
from dispatch.incident import flows as incident_flows
from dispatch.participant import service as participant_service
from dispatch.plugin import service as plugin_service
from dispatch.plugins.dispatch_slack import service as dispatch_slack_service
from dispatch.plugins.dispatch_slack.bolt import app
from dispatch.plugins.dispatch_slack.case.enums import (
Expand All @@ -22,6 +23,7 @@
CaseReportActions,
CaseResolveActions,
CaseShortcutCallbacks,
CaseSnoozeActions,
)
from dispatch.plugins.dispatch_slack.case.messages import create_case_message
from dispatch.plugins.dispatch_slack.decorators import message_dispatcher
Expand Down Expand Up @@ -396,6 +398,91 @@ def join_incident_button_click(
)


@app.action(CaseNotificationActions.snooze, middleware=[button_context_middleware, db_middleware])
def snooze_button_click(
ack: Ack, body: dict, db_session: Session, context: BoltContext, client: WebClient
):
ack()

blocks = [
Context(elements=[MarkdownText(text="Use this form to create a snooze filter.")]),
description_input(),
]

modal = Modal(
title="Snooze Signal",
blocks=blocks,
submit="Update",
close="Close",
callback_id=CaseSnoozeActions.submit,
private_metadata=context["subject"].json(),
).build()
client.views_open(trigger_id=body["trigger_id"], view=modal)


def ack_snooze_submission_event(ack: Ack) -> None:
"""Handles the add snooze submission event acknowledgement."""
modal = Modal(
title="Add Snooze",
close="Close",
blocks=[Section(text="Adding snooze submission event...")],
).build()
ack(response_action="update", view=modal)


@app.view(
CaseSnoozeActions.submit,
middleware=[action_context_middleware, db_middleware, user_middleware, modal_submit_middleware],
)
def handle_snooze_submission_event(
ack: Ack,
body,
client: WebClient,
context: BoltContext,
db_session: Session,
form_data: dict,
user: DispatchUser,
):
ack_snooze_submission_event(ack=ack)

case = case_service.get(db_session=db_session, case_id=context["subject"].id)

mfa_plugin = plugin_service.get_active_instance(
db_session=db_session, project_id=case.project.id, plugin_type="mfa"
)
if not mfa_plugin:
print("Case assignee not mfa'd. No plugin of type mfa enabled.")
else:
email = context["user"].email
username, _ = email.split("@")
response = mfa_plugin.instance.send_push_notification(
username=username, type="Are you creating a signal filter in Dispatch?"
)

if response.get("result") == "allow":
modal = Modal(
title="Add Snooze",
close="Close",
blocks=[Section(text="Adding Snooze... Success!")],
).build()

client.views_update(
view_id=body["view"]["id"],
view=modal,
)
else:
modal = Modal(
title="Add Snooze",
close="Close",
blocks=[Section(text="Adding Snooze failed, you must accept the MFA prompt.")],
).build()

client.views_update(
view_id=body["view"]["id"],
view=modal,
)


@app.action(CaseNotificationActions.edit, middleware=[button_context_middleware, db_middleware])
def edit_button_click(
ack: Ack, body: dict, db_session: Session, context: BoltContext, client: WebClient
Expand Down
6 changes: 6 additions & 0 deletions src/dispatch/plugins/dispatch_slack/case/messages.py
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,12 @@ def create_case_message(case: Case, channel_id: str):
[
Actions(
elements=[
Button(
text="Snooze",
action_id=CaseNotificationActions.snooze,
style="primary",
value=button_metadata,
),
Button(
text="Edit",
action_id=CaseNotificationActions.edit,
Expand Down