Skip to content

Commit

Permalink
Merge pull request #220 from metagov/oc-auth
Browse files Browse the repository at this point in the history
Use OAuth for Open Collective plugin
  • Loading branch information
amyxzhang authored Jan 30, 2023
2 parents 9c57e86 + f94304b commit 9197896
Show file tree
Hide file tree
Showing 6 changed files with 227 additions and 8 deletions.
2 changes: 1 addition & 1 deletion metagov/metagov/core/handlers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
165 changes: 165 additions & 0 deletions metagov/metagov/plugins/opencollective/handlers.py
Original file line number Diff line number Diff line change
@@ -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

29 changes: 24 additions & 5 deletions metagov/metagov/plugins/opencollective/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,33 +25,36 @@
@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"

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"])
Expand All @@ -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}")
Expand All @@ -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"]})
Expand Down
33 changes: 33 additions & 0 deletions metagov/metagov/plugins/opencollective/queries.py
Original file line number Diff line number Diff line change
Expand Up @@ -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!) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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"""
Expand Down
4 changes: 3 additions & 1 deletion metagov/metagov/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -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),
}
}

Expand Down

0 comments on commit 9197896

Please sign in to comment.