diff --git a/metagov/metagov/core/handlers.py b/metagov/metagov/core/handlers.py index 7f90b339..b27d466a 100644 --- a/metagov/metagov/core/handlers.py +++ b/metagov/metagov/core/handlers.py @@ -159,7 +159,7 @@ def handle_oauth_authorize( redirect_uri, type, community_slug, metagov_id = self.check_request_values(request, redirect_uri, type, community_slug, metagov_id) - logger.debug(f"Handling {type} authorization request for {plugin_name}' to community '{community_slug}'") + logger.debug(f"Handling {type} authorization request for '{plugin_name}' to community '{community_slug}'") # Get plugin handler if not plugin_registry.get(plugin_name): diff --git a/metagov/metagov/plugins/opencollective/handlers.py b/metagov/metagov/plugins/opencollective/handlers.py new file mode 100644 index 00000000..09ea575d --- /dev/null +++ b/metagov/metagov/plugins/opencollective/handlers.py @@ -0,0 +1,165 @@ +import logging +import requests +from django.conf import settings + +import metagov.plugins.opencollective.queries as Queries +from django.http.response import HttpResponseBadRequest, HttpResponseRedirect +from metagov.core.errors import PluginAuthError, PluginErrorInternal +from metagov.core.plugin_manager import AuthorizationType +from metagov.core.models import ProcessStatus +from metagov.plugins.opencollective.models import OpenCollective, OPEN_COLLECTIVE_URL, OPEN_COLLECTIVE_GRAPHQL +from requests.models import PreparedRequest +from metagov.core.handlers import PluginRequestHandler + + + + +logger = logging.getLogger(__name__) + +open_collective_settings = settings.METAGOV_SETTINGS["OPENCOLLECTIVE"] +OC_CLIENT_ID = open_collective_settings["CLIENT_ID"] +OC_CLIENT_SECRET = open_collective_settings["CLIENT_SECRET"] +BOT_ACCOUNT_NAME_SUBSTRING = "governance bot" + +class NonBotAccountError(PluginAuthError): + default_code = "non_bot_account" + default_detail = f"The Open Collective account name must contains string '{BOT_ACCOUNT_NAME_SUBSTRING}' (case insensitive)." + + +class NotOneCollectiveError(PluginAuthError): + default_code = "not_one_collective" + default_detail = f"The Open Collective account must be a member of exactly 1 collective." + +class InsufficientPermissions(PluginAuthError): + default_code = "insufficient_permissions" + default_detail = f"The Open Collective account does not have sufficient permissions. Account must be an admin on the collective." + +class OpenCollectiveRequestHandler(PluginRequestHandler): + def construct_oauth_authorize_url(self, type: str, community=None): + if not OC_CLIENT_ID: + raise PluginAuthError(detail="Client ID not configured") + if not OC_CLIENT_SECRET: + raise PluginAuthError(detail="Client secret not configured") + + admin_scopes = ['email', 'account', 'expenses', 'conversations', 'webhooks'] + # if type == AuthorizationType.APP_INSTALL: + # elif type == AuthorizationType.USER_LOGIN: + + return f"{OPEN_COLLECTIVE_URL}/oauth/authorize?response_type=code&client_id={OC_CLIENT_ID}&scope={','.join(admin_scopes)}" + + def handle_oauth_callback( + self, + type: str, + code: str, + redirect_uri: str, + community, + request, + state=None, + external_id=None, + *args, + **kwargs, + ): + """ + OAuth2 callback endpoint handler for authorization code grant type. + This function does two things: + 1) completes the authorization flow, + 2) enables the OC plugin for the specified community + + + type : AuthorizationType.APP_INSTALL or AuthorizationType.USER_LOGIN + code : authorization code from the server (OC) + redirect_uri : redirect uri from the Driver to redirect to on completion + community : the Community to enable OC for + state : optional state to pass along to the redirect_uri + """ + logger.debug(f"> auth_callback for oc") + + response = _exchange_code(code) + logger.info(f"---- {response} ----") + user_access_token = response["access_token"] + + # Get user info + resp = requests.post( + OPEN_COLLECTIVE_GRAPHQL, + json={"query": Queries.me}, + headers={"Authorization": f"Bearer {user_access_token}"} + ) + logger.debug(resp.request.headers) + if not resp.ok: + logger.error(f"OC req failed: {resp.status_code} {resp.reason}") + raise PluginAuthError(detail="Error getting user info for installing user") + response = resp.json() + logger.info(response) + account_name = response['data']['me']['name'] or '' + member_of = response['data']['me']['memberOf'] + if not BOT_ACCOUNT_NAME_SUBSTRING in account_name.lower(): + logger.error(f"OC bad account name: {account_name}") + raise NonBotAccountError + + if not member_of or member_of['totalCount'] != 1: + raise NotOneCollectiveError + + collective = member_of['nodes'][0]['account']['slug'] + logger.info('collective: ') + logger.info(collective) + + + if type == AuthorizationType.APP_INSTALL: + plugin_config = {"collective_slug": collective, "access_token": user_access_token} + plugin = OpenCollective.objects.create( + name="opencollective", community=community, config=plugin_config, community_platform_id=collective + ) + logger.debug(f"Created OC plugin: {plugin}") + try: + plugin.initialize() + except PluginErrorInternal as e: + plugin.delete() + if 'permission' in e.detail: + raise InsufficientPermissions + else: + raise PluginAuthError + + params = { + # Metagov community that has the OC plugin enabled + "community": community.slug, + # (Optional) State that was originally passed from Driver, so it can validate it + "state": state, + # Collective that the user installed PolicyKit to + "collective": collective, + } + url = add_query_parameters(redirect_uri, params) + return HttpResponseRedirect(url) + + elif type == AuthorizationType.USER_LOGIN: + # TODO Implement + # Validate that is member of collective + + # Add some params to redirect + params = { "state": state } + url = add_query_parameters(redirect_uri, params) + return HttpResponseRedirect(url) + + return HttpResponseBadRequest() + + +def _exchange_code(code): + data = { + "client_id": OC_CLIENT_ID, + "client_secret": OC_CLIENT_SECRET, + "grant_type": "authorization_code", + "code": code, + "redirect_uri": f"{settings.SERVER_URL}/auth/opencollective/callback", + } + resp = requests.post(f"{OPEN_COLLECTIVE_URL}/oauth/token", data=data) + if not resp.ok: + logger.error(f"OC auth failed: {resp.status_code} {resp.reason}") + raise PluginAuthError + + return resp.json() + + +def add_query_parameters(url, params): + req = PreparedRequest() + req.prepare_url(url, params) + return req.url + diff --git a/metagov/metagov/plugins/opencollective/models.py b/metagov/metagov/plugins/opencollective/models.py index b5881f0c..f3953e95 100644 --- a/metagov/metagov/plugins/opencollective/models.py +++ b/metagov/metagov/plugins/opencollective/models.py @@ -25,18 +25,18 @@ @Registry.plugin class OpenCollective(Plugin): name = "opencollective" - auth_type = AuthType.API_KEY + auth_type = AuthType.OAUTH config_schema = { "type": "object", "additionalProperties": False, "properties": { - "api_key": {"type": "string", "description": "API Key for a user that is an admin on this collective."}, + "access_token": {"type": "string", "description": "Access token for Open Collective account"}, "collective_slug": { "type": "string", "description": "Open Collective slug", }, }, - "required": ["api_key", "collective_slug"], + "required": ["access_token", "collective_slug"], } community_platform_id_key = "collective_slug" @@ -44,14 +44,17 @@ class Meta: proxy = True def initialize(self): + # Fetch info about collective slug = self.config["collective_slug"] response = self.run_query(Queries.collective, {"slug": slug}) result = response["collective"] if result is None: raise PluginErrorInternal(f"Collective '{slug}' not found.") - logger.info("Initialized Open Collective: " + str(result)) + # Create webhook for listening to events on OC + self.create_webhook() + # Store collective information in plugin state self.state.set("collective_name", result["name"]) self.state.set("collective_id", result["id"]) self.state.set("collective_legacy_id", result["legacyId"]) @@ -62,12 +65,13 @@ def initialize(self): ] self.state.set("project_legacy_ids", project_legacy_ids) + logger.info("Initialized Open Collective: " + str(result)) def run_query(self, query, variables): resp = requests.post( OPEN_COLLECTIVE_GRAPHQL, json={"query": query, "variables": variables}, - headers={"Api-Key": f"{self.config['api_key']}"}, + headers={"Authorization": f"Bearer {self.config['access_token']}"}, ) if not resp.ok: logger.error(f"Query failed with {resp.status_code} {resp.reason}: {query}") @@ -76,9 +80,24 @@ def run_query(self, query, variables): result = resp.json() if result.get("errors"): msg = ",".join([e["message"] for e in result["errors"]]) + logger.error(f"Query failed: {msg}") raise PluginErrorInternal(msg) return result["data"] + def create_webhook(self): + webhook_url = f"{settings.SERVER_URL}/api/hooks/{self.name}/{self.community.slug}" + logger.debug(f"Creating OC webhook: {webhook_url}") + result = self.run_query(Queries.create_webhook, { + "webhook": { + "account": { + "slug": self.config["collective_slug"] + }, + "activityType": "ACTIVITY_ALL", + "webhookUrl": webhook_url + } + }) + logger.debug(result) + @Registry.action(slug="list-members", description="list members of the collective") def list_members(self): result = self.run_query(Queries.members, {"slug": self.config["collective_slug"]}) diff --git a/metagov/metagov/plugins/opencollective/queries.py b/metagov/metagov/plugins/opencollective/queries.py index e2b76690..5923cece 100644 --- a/metagov/metagov/plugins/opencollective/queries.py +++ b/metagov/metagov/plugins/opencollective/queries.py @@ -156,6 +156,39 @@ } """ +me = ( + """ +{ + me { + id + name + email + memberOf(accountType: COLLECTIVE) { + totalCount + nodes { + account { + name + slug + } + } + } + } +} + """ +) + +create_webhook = ( + """ +mutation CreateWebhook($webhook: WebhookCreateInput!) { + createWebhook(webhook: $webhook) { + id + activityType + webhookUrl + } +} +""" +) + conversation = ( """ query Conversation($id: String!) { diff --git a/metagov/metagov/plugins/opencollective/tests/test_opencollective.py b/metagov/metagov/plugins/opencollective/tests/test_opencollective.py index 2f2bdbd2..f47a1653 100644 --- a/metagov/metagov/plugins/opencollective/tests/test_opencollective.py +++ b/metagov/metagov/plugins/opencollective/tests/test_opencollective.py @@ -12,7 +12,7 @@ def setUp(self): json={"data": {"collective": {"name": "my community", "id": "xyz", "legacyId": 123}}}, ) # enable the plugin - self.enable_plugin(name="opencollective", config={"collective_slug": "mycollective", "api_key": "empty"}) + self.enable_plugin(name="opencollective", config={"collective_slug": "mycollective", "access_token": "empty"}) def test_init_works(self): """Plugin is properly initialized""" diff --git a/metagov/metagov/settings.py b/metagov/metagov/settings.py index dd2540c5..4fe885b3 100644 --- a/metagov/metagov/settings.py +++ b/metagov/metagov/settings.py @@ -111,7 +111,9 @@ "API_KEY": env("SENDGRID_API_KEY", default=default_val) }, "OPENCOLLECTIVE": { - "USE_STAGING": env("OPENCOLLECTIVE_USE_STAGING", default=False) + "USE_STAGING": env("OPENCOLLECTIVE_USE_STAGING", default=False), + "CLIENT_ID": env("OPENCOLLECTIVE_CLIENT_ID", default=default_val), + "CLIENT_SECRET": env("OPENCOLLECTIVE_CLIENT_SECRET", default=default_val), } }