Browse Source

feat(slack): Initial draft of Slack connector

Add support for Slack within integrations, as well as Identity capture and a generic OAuth2 abstraction.
David Cramer 7 years ago
parent
commit
4bfe92279e

+ 6 - 3
src/sentry/api/endpoints/organization_config_integrations.py

@@ -15,9 +15,12 @@ class OrganizationConfigIntegrationsEndpoint(OrganizationEndpoint):
                     'id': provider.id,
                     'id': provider.id,
                     'name': provider.name,
                     'name': provider.name,
                     'config': provider.get_config(),
                     'config': provider.get_config(),
-                    'setupUri': '/organizations/{}/integrations/{}/setup/'.format(
-                        organization.slug,
-                        provider.id,
+                    'setupDialog': dict(
+                        url='/organizations/{}/integrations/{}/setup/'.format(
+                            organization.slug,
+                            provider.id,
+                        ),
+                        **provider.setup_dialog_config
                     )
                     )
                 }
                 }
             )
             )

+ 3 - 1
src/sentry/conf/server.py

@@ -1174,7 +1174,9 @@ SENTRY_ONPREMISE = True
 # when checking REMOTE_ADDR ip addresses
 # when checking REMOTE_ADDR ip addresses
 SENTRY_USE_X_FORWARDED_FOR = True
 SENTRY_USE_X_FORWARDED_FOR = True
 
 
-SENTRY_DEFAULT_INTEGRATIONS = ()
+SENTRY_DEFAULT_INTEGRATIONS = (
+    'sentry.integrations.slack.SlackIntegration',
+)
 
 
 
 
 def get_raven_config():
 def get_raven_config():

+ 1 - 0
src/sentry/integrations/__init__.py

@@ -2,6 +2,7 @@ from __future__ import absolute_import
 
 
 from .base import *  # NOQA
 from .base import *  # NOQA
 from .manager import IntegrationManager  # NOQA
 from .manager import IntegrationManager  # NOQA
+from .oauth import *  # NOQA
 from .view import *  # NOQA
 from .view import *  # NOQA
 
 
 
 

+ 14 - 1
src/sentry/integrations/base.py

@@ -24,6 +24,12 @@ class Integration(object):
     # a human readable name (e.g. 'Slack')
     # a human readable name (e.g. 'Slack')
     name = None
     name = None
 
 
+    # configuration for the setup dialog
+    setup_dialog_config = {
+        'width': 600,
+        'height': 600,
+    }
+
     def get_logger(self):
     def get_logger(self):
         return logging.getLogger('sentry.integration.%s' % (self.get_id(), ))
         return logging.getLogger('sentry.integration.%s' % (self.get_id(), ))
 
 
@@ -53,7 +59,14 @@ class Integration(object):
         >>>         'required': True,
         >>>         'required': True,
         >>>     }]
         >>>     }]
         """
         """
-        raise NotImplementedError
+        return []
+
+    def is_configured(self):
+        """
+        Return a boolean describing whether this integration should be made
+        available (e.g. per system-configuration).
+        """
+        return True
 
 
     def build_integration(self, state):
     def build_integration(self, state):
         """
         """

+ 54 - 9
src/sentry/integrations/helper.py

@@ -5,10 +5,14 @@ __all__ = ['PipelineHelper']
 import json
 import json
 import logging
 import logging
 
 
+from django.db import IntegrityError, transaction
 from django.http import HttpResponse
 from django.http import HttpResponse
 
 
 from sentry.api.serializers import serialize
 from sentry.api.serializers import serialize
-from sentry.models import Integration
+from sentry.models import (
+    Identity, IdentityProvider, IdentityStatus, Integration, Organization,
+    UserIdentity
+)
 from sentry.utils.hashlib import md5_text
 from sentry.utils.hashlib import md5_text
 from sentry.utils.http import absolute_uri
 from sentry.utils.http import absolute_uri
 from sentry.web.helpers import render_to_response
 from sentry.web.helpers import render_to_response
@@ -34,13 +38,20 @@ logger = logging.getLogger('sentry.integrations')
 
 
 
 
 class PipelineHelper(object):
 class PipelineHelper(object):
+    logger = logger
+
     @classmethod
     @classmethod
-    def get_for_request(cls, request, organization, provider_id):
+    def get_for_request(cls, request, provider_id):
         session = request.session.get(SESSION_KEY, {})
         session = request.session.get(SESSION_KEY, {})
         if not session:
         if not session:
             logger.error('integrations.setup.missing-session-data')
             logger.error('integrations.setup.missing-session-data')
             return None
             return None
 
 
+        # TODO(dcramer): enforce access check
+        organization = Organization.objects.get(
+            id=session['org'],
+        )
+
         if session.get('int'):
         if session.get('int'):
             integration = Integration.objects.get(
             integration = Integration.objects.get(
                 id=session['int'],
                 id=session['int'],
@@ -49,6 +60,14 @@ class PipelineHelper(object):
         else:
         else:
             integration = None
             integration = None
 
 
+        if provider_id != session['pro']:
+            logger.error('integrations.setup.invalid-provider')
+            return None
+
+        if session['uid'] != request.user.id:
+            logger.error('integrations.setup.invalid-uid')
+            return None
+
         instance = cls(
         instance = cls(
             request=request,
             request=request,
             organization=organization,
             organization=organization,
@@ -56,6 +75,7 @@ class PipelineHelper(object):
             provider_id=provider_id,
             provider_id=provider_id,
             step=session['step'],
             step=session['step'],
             dialog=bool(session['dlg']),
             dialog=bool(session['dlg']),
+            state=session['state'],
         )
         )
         if instance.signature != session['sig']:
         if instance.signature != session['sig']:
             logger.error('integrations.setup.invalid-signature')
             logger.error('integrations.setup.invalid-signature')
@@ -98,14 +118,13 @@ class PipelineHelper(object):
             'int': self.integration.id if self.integration else '',
             'int': self.integration.id if self.integration else '',
             'sig': self.signature,
             'sig': self.signature,
             'step': self.step,
             'step': self.step,
-            'state': {},
+            'state': self.state,
             'dlg': int(self.dialog),
             'dlg': int(self.dialog),
         }
         }
         self.request.session.modified = True
         self.request.session.modified = True
 
 
     def get_redirect_url(self):
     def get_redirect_url(self):
-        return absolute_uri('/organizations/{}/integrations/{}/setup/'.format(
-            self.organization.slug,
+        return absolute_uri('/extensions/{}/setup/'.format(
             self.provider.id,
             self.provider.id,
         ))
         ))
 
 
@@ -152,7 +171,7 @@ class PipelineHelper(object):
     def error(self, message):
     def error(self, message):
         # TODO(dcramer): this needs to handle the dialog
         # TODO(dcramer): this needs to handle the dialog
         self.clear_session()
         self.clear_session()
-        return self._jsonp_response({'detail': message}, False)
+        return self._dialog_response({'detail': message}, False)
 
 
     def bind_state(self, key, value):
     def bind_state(self, key, value):
         self.state[key] = value
         self.state[key] = value
@@ -169,14 +188,40 @@ class PipelineHelper(object):
                 name=data.get('name', self.provider.name),
                 name=data.get('name', self.provider.name),
             )
             )
         else:
         else:
-            self.integration = Integration.objects.create(
+            self.integration, _ = Integration.objects.get_or_create(
                 provider=self.provider.id,
                 provider=self.provider.id,
-                metadata=data.get('metadata', {}),
-                name=data.get('name', data['external_id']),
                 external_id=data['external_id'],
                 external_id=data['external_id'],
+                defaults={
+                    'metadata': data.get('metadata', {}),
+                    'name': data.get('name', data['external_id']),
+                }
             )
             )
             self.integration.add_organization(self.organization.id)
             self.integration.add_organization(self.organization.id)
 
 
+        id_config = data.get('identity')
+        if id_config:
+            idp = IdentityProvider.get(id_config['type'], id_config['instance'])
+            identity, created = Identity.objects.get_or_create(
+                idp=idp,
+                external_id=id_config['external_id'],
+                defaults={
+                    'status': IdentityStatus.VALID,
+                    'scopes': id_config['scopes'],
+                    'data': id_config['data'],
+                },
+            )
+            if not created:
+                if identity.status != IdentityStatus.VALID:
+                    identity.update(status=IdentityStatus.VALID)
+            try:
+                with transaction.atomic():
+                    UserIdentity.objects.create(
+                        user=self.request.user,
+                        identity=identity,
+                    )
+            except IntegrityError:
+                pass
+
         return self._dialog_response(serialize(self.integration, self.request.user), True)
         return self._dialog_response(serialize(self.integration, self.request.user), True)
 
 
     def _dialog_response(self, data, success):
     def _dialog_response(self, data, success):

+ 5 - 2
src/sentry/integrations/manager.py

@@ -14,10 +14,13 @@ class IntegrationManager(object):
         self.__values = {}
         self.__values = {}
 
 
     def __iter__(self):
     def __iter__(self):
-        return (self.get(k) for k in six.iterkeys(self.__values))
+        return iter(self.all())
 
 
     def all(self):
     def all(self):
-        return iter(self)
+        for key in six.iterkeys(self.__values):
+            provider = self.get(key)
+            if provider.is_configured():
+                yield provider
 
 
     def get(self, key, **kwargs):
     def get(self, key, **kwargs):
         try:
         try:

+ 167 - 0
src/sentry/integrations/oauth.py

@@ -0,0 +1,167 @@
+from __future__ import absolute_import, print_function
+
+__all__ = ['OAuth2Integration', 'OAuth2CallbackView', 'OAuth2LoginView']
+
+from six.moves.urllib.parse import parse_qsl, urlencode
+from uuid import uuid4
+
+from sentry.http import safe_urlopen, safe_urlread
+from sentry.utils import json
+from sentry.utils.http import absolute_uri
+
+from .base import Integration
+from .view import PipelineView
+
+ERR_INVALID_STATE = 'An error occurred while validating your request.'
+
+
+class OAuth2Integration(Integration):
+    oauth_access_token_url = ''
+    oauth_authorize_url = ''
+    oauth_client_id = ''
+    oauth_client_secret = ''
+    oauth_refresh_token_url = ''
+    oauth_scopes = ()
+
+    def is_configured(self):
+        return (
+            self.oauth_client_id and
+            self.oauth_client_secret and
+            self.oauth_access_token_url and
+            self.oauth_authorize_url
+        )
+
+    def get_pipeline(self):
+        return [
+            OAuth2LoginView(
+                authorize_url=self.oauth_authorize_url,
+                client_id=self.oauth_client_id,
+                scope=' '.join(self.oauth_scopes),
+            ),
+            OAuth2CallbackView(
+                access_token_url=self.oauth_access_token_url,
+                client_id=self.oauth_client_id,
+                client_secret=self.oauth_client_secret,
+            ),
+        ]
+
+
+class OAuth2LoginView(PipelineView):
+    authorize_url = None
+    client_id = None
+    scope = ''
+
+    def __init__(self, authorize_url=None, client_id=None, scope=None, *args, **kwargs):
+        super(OAuth2LoginView, self).__init__(*args, **kwargs)
+        if authorize_url is not None:
+            self.authorize_url = authorize_url
+        if client_id is not None:
+            self.client_id = client_id
+        if scope is not None:
+            self.scope = scope
+
+    def get_scope(self):
+        return self.scope
+
+    def get_authorize_url(self):
+        return self.authorize_url
+
+    def get_authorize_params(self, state, redirect_uri):
+        return {
+            'client_id': self.client_id,
+            'response_type': "code",
+            'scope': self.get_scope(),
+            'state': state,
+            'redirect_uri': redirect_uri,
+        }
+
+    def dispatch(self, request, helper):
+        if 'code' in request.GET:
+            return helper.next_step()
+
+        state = uuid4().hex
+
+        params = self.get_authorize_params(
+            state=state,
+            redirect_uri=absolute_uri(helper.get_redirect_url()),
+        )
+        redirect_uri = '{}?{}'.format(self.get_authorize_url(), urlencode(params))
+
+        helper.bind_state('state', state)
+
+        return self.redirect(redirect_uri)
+
+
+class OAuth2CallbackView(PipelineView):
+    access_token_url = None
+    client_id = None
+    client_secret = None
+
+    def __init__(self, access_token_url=None, client_id=None, client_secret=None, *args, **kwargs):
+        super(OAuth2CallbackView, self).__init__(*args, **kwargs)
+        if access_token_url is not None:
+            self.access_token_url = access_token_url
+        if client_id is not None:
+            self.client_id = client_id
+        if client_secret is not None:
+            self.client_secret = client_secret
+
+    def get_token_params(self, code, redirect_uri):
+        return {
+            'grant_type': 'authorization_code',
+            'code': code,
+            'redirect_uri': redirect_uri,
+            'client_id': self.client_id,
+            'client_secret': self.client_secret,
+        }
+
+    def exchange_token(self, request, helper, code):
+        # TODO: this needs the auth yet
+        data = self.get_token_params(
+            code=code,
+            redirect_uri=absolute_uri(helper.get_redirect_url()),
+        )
+        req = safe_urlopen(self.access_token_url, data=data)
+        body = safe_urlread(req)
+        if req.headers['Content-Type'].startswith('application/x-www-form-urlencoded'):
+            return dict(parse_qsl(body))
+        return json.loads(body)
+
+    def dispatch(self, request, helper):
+        error = request.GET.get('error')
+        state = request.GET.get('state')
+        code = request.GET.get('code')
+
+        if error:
+            helper.logger.info('auth.token-exchange-error', extra={
+                'error': error,
+            })
+            return helper.error(error)
+
+        if state != helper.fetch_state('state'):
+            helper.logger.info('auth.token-exchange-error', extra={
+                'error': 'invalid_state',
+            })
+            return helper.error(ERR_INVALID_STATE)
+
+        data = self.exchange_token(request, helper, code)
+
+        if 'error_description' in data:
+            helper.logger.info('auth.token-exchange-error', extra={
+                'error': data.get('error'),
+            })
+            return helper.error(data['error_description'])
+
+        if 'error' in data:
+            helper.logger.info('auth.token-exchange-error', extra={
+                'error': data['error'],
+            })
+            return helper.error(
+                'There was an error when retrieving a token from the upstream service.')
+
+        # we can either expect the API to be implicit and say "im looking for
+        # blah within state data" or we need to pass implementation + call a
+        # hook here
+        helper.bind_state('data', data)
+
+        return helper.next_step()

+ 5 - 0
src/sentry/integrations/slack/__init__.py

@@ -0,0 +1,5 @@
+from __future__ import absolute_import
+
+from sentry.utils.imports import import_submodules
+
+import_submodules(globals(), __name__, __path__)

+ 52 - 0
src/sentry/integrations/slack/integration.py

@@ -0,0 +1,52 @@
+from __future__ import absolute_import
+
+from sentry import options
+from sentry.integrations import OAuth2Integration
+
+options.register('slack.client-id')
+options.register('slack.client-secret')
+options.register('slack.verification-token')
+
+
+class SlackIntegration(OAuth2Integration):
+    id = 'slack'
+    name = 'Slack'
+
+    oauth_access_token_url = 'https://slack.com/api/oauth.access'
+    oauth_authorize_url = 'https://slack.com/oauth/authorize'
+    oauth_client_id = options.get('slack.client-id')
+    oauth_client_secret = options.get('slack.client-secret')
+    oauth_scopes = (
+        'bot',
+        'chat:write:bot',
+        'commands',
+        'team:read',
+    )
+
+    def build_integration(self, state):
+        data = state['data']
+        assert data['ok']
+        return {
+            'external_id': data['team_id'],
+            'name': data['team_name'],
+            # TODO(dcramer): we should probably store an Identity for the bot,
+            # and just skip associating them with a user?
+            'metadata': {
+                'bot_access_token': data['bot']['bot_access_token'],
+                'bot_user_id': data['bot']['bot_user_id'],
+                'scopes': sorted(data['scope'].split(',')),
+            },
+            'identity': self.build_identity(state)
+        }
+
+    def build_identity(self, state):
+        data = state['data']
+        return {
+            'type': 'slack',
+            'instance': 'slack.com',
+            'external_id': data['user_id'],
+            'scopes': sorted(data['scope'].split(',')),
+            'data': {
+                'access_token': data['access_token'],
+            },
+        }

+ 8 - 0
src/sentry/models/identity.py

@@ -36,6 +36,14 @@ class IdentityProvider(Model):
         db_table = 'sentry_identityprovider'
         db_table = 'sentry_identityprovider'
         unique_together = (('type', 'instance'),)
         unique_together = (('type', 'instance'),)
 
 
+    @classmethod
+    def get(cls, type, instance):
+        # TODO(dcramer): add caching
+        return cls.objects.get_or_create(
+            type=type,
+            instance=instance,
+        )[0]
+
 
 
 class Identity(Model):
 class Identity(Model):
     """
     """

Some files were not shown because too many files changed in this diff