diff --git a/pillar/api/blender_cloud/__init__.py b/pillar/api/blender_cloud/__init__.py index 6a621274..8bf66224 100644 --- a/pillar/api/blender_cloud/__init__.py +++ b/pillar/api/blender_cloud/__init__.py @@ -24,7 +24,8 @@ def blender_cloud_addon_version(): def setup_app(app, url_prefix): - from . import texture_libs, home_project + from . import texture_libs, home_project, subscription texture_libs.setup_app(app, url_prefix=url_prefix) home_project.setup_app(app, url_prefix=url_prefix) + subscription.setup_app(app, url_prefix=url_prefix) diff --git a/pillar/api/blender_cloud/subscription.py b/pillar/api/blender_cloud/subscription.py new file mode 100644 index 00000000..ed1310f5 --- /dev/null +++ b/pillar/api/blender_cloud/subscription.py @@ -0,0 +1,118 @@ +import logging +import typing + +from flask import current_app, Blueprint + +from pillar.api.utils import authorization + +log = logging.getLogger(__name__) +blueprint = Blueprint('blender_cloud.subscription', __name__) + + +def fetch_subscription_info(email: str) -> typing.Optional[dict]: + """Returns the user info dict from the external subscriptions management server. + + :returns: the store user info, or None if the user can't be found or there + was an error communicating. A dict like this is returned: + { + "shop_id": 700, + "cloud_access": 1, + "paid_balance": 314.75, + "balance_currency": "EUR", + "start_date": "2014-08-25 17:05:46", + "expiration_date": "2016-08-24 13:38:45", + "subscription_status": "wc-active", + "expiration_date_approximate": true + } + """ + + import requests + from requests.adapters import HTTPAdapter + + external_subscriptions_server = current_app.config['EXTERNAL_SUBSCRIPTIONS_MANAGEMENT_SERVER'] + + if log.isEnabledFor(logging.DEBUG): + import urllib.parse + + log_email = urllib.parse.quote(email) + log.debug('Connecting to store at %s?blenderid=%s', + external_subscriptions_server, log_email) + + # Retry a few times when contacting the store. + s = requests.Session() + s.mount(external_subscriptions_server, HTTPAdapter(max_retries=5)) + r = s.get(external_subscriptions_server, params={'blenderid': email}, + verify=current_app.config['TLS_CERT_FILE']) + + if r.status_code != 200: + log.warning("Error communicating with %s, code=%i, unable to check " + "subscription status of user %s", + external_subscriptions_server, r.status_code, email) + return None + + store_user = r.json() + + if log.isEnabledFor(logging.DEBUG): + import json + log.debug('Received JSON from store API: %s', + json.dumps(store_user, sort_keys=False, indent=4)) + + return store_user + + +@blueprint.route('/update-subscription') +@authorization.require_login() +def update_subscription(): + """Updates the subscription status of the current user. + + Returns an empty HTTP response. + """ + + import pprint + from pillar.api import blender_id, service + from pillar.api.utils import authentication + + my_log: logging.Logger = log.getChild('update_subscription') + user_id = authentication.current_user_id() + + bid_user = blender_id.fetch_blenderid_user() + if not bid_user: + my_log.warning('Logged in user %s has no BlenderID account! ' + 'Unable to update subscription status.', user_id) + return + + # Use the Blender ID email address to check with the store. At least that reduces the + # number of email addresses that could be out of sync to two (rather than three when we + # use the email address from our local database). + try: + email = bid_user['email'] + except KeyError: + my_log.error('Blender ID response did not include an email address, ' + 'unable to update subscription status: %s', + pprint.pformat(bid_user, compact=True)) + return + store_user = fetch_subscription_info(email) or {} + + # Handle the role changes via the badger service functionality. + grant_subscriber = store_user.get('cloud_access', 0) == 1 + grant_demo = bid_user.get('roles', {}).get('cloud_demo', False) + + is_subscriber = authorization.user_has_role('subscriber') + is_demo = authorization.user_has_role('demo') + + if grant_subscriber != is_subscriber: + action = 'grant' if grant_subscriber else 'revoke' + log.info('%sing subscriber role to user %s', action, user_id) + service.do_badger(action, 'subscriber', user_id=user_id) + + if grant_demo != is_demo: + action = 'grant' if grant_demo else 'revoke' + log.info('%sing demo role to user %s', action, user_id) + service.do_badger(action, 'demo', user_id=user_id) + + return '', 204 + + +def setup_app(app, url_prefix): + log.info('Registering blueprint at %s', url_prefix) + app.register_api_blueprint(blueprint, url_prefix=url_prefix) diff --git a/pillar/api/blender_id.py b/pillar/api/blender_id.py index 86fc57d0..3fab2fe3 100644 --- a/pillar/api/blender_id.py +++ b/pillar/api/blender_id.py @@ -88,7 +88,8 @@ def upsert_user(db_user, blender_id_user_id): if 'subscriber' in db_user.get('groups', []): log.error('Non-ObjectID string found in user.groups: %s', db_user) - raise wz_exceptions.InternalServerError('Non-ObjectID string found in user.groups: %s' % db_user) + raise wz_exceptions.InternalServerError( + 'Non-ObjectID string found in user.groups: %s' % db_user) r = {} for retry in range(5): @@ -237,5 +238,50 @@ def find_user_in_db(blender_id_user_id, user_info): return db_user +def fetch_blenderid_user() -> dict: + """Returns the user info of the currently logged in user from BlenderID. + + Returns an empty dict if communication fails. + + Example dict: + { + "email": "some@email.example.com", + "full_name": "dr. Sybren A. St\u00fcvel", + "id": 5555, + "roles": { + "admin": true, + "bfct_trainer": false, + "cloud_single_member": true, + "conference_speaker": true, + "network_member": true + } + } + + """ + + import urllib.parse + import httplib2 # used by the oauth2 package + + bid_url = urllib.parse.urljoin(blender_id_endpoint(), 'api/user') + log.debug('Fetching user info from %s', bid_url) + + try: + bid_resp = current_app.oauth_blender_id.get(bid_url) + except httplib2.HttpLib2Error: + log.exception('Error getting %s from BlenderID', bid_url) + return {} + + if bid_resp.status != 200: + log.warning('Error %i from BlenderID %s: %s', bid_resp.status, bid_url, bid_resp.data) + return {} + + if not bid_resp.data: + log.warning('Empty data returned from BlenderID %s', bid_url) + return {} + + log.debug('BlenderID returned %s', bid_resp.data) + return bid_resp.data + + def setup_app(app, url_prefix): app.register_api_blueprint(blender_id, url_prefix=url_prefix) diff --git a/pillar/api/service.py b/pillar/api/service.py index 3e165a2d..7bb4a07e 100644 --- a/pillar/api/service.py +++ b/pillar/api/service.py @@ -3,11 +3,14 @@ import logging import blinker +import bson + from flask import Blueprint, current_app, request +from werkzeug import exceptions as wz_exceptions + from pillar.api import local_auth from pillar.api.utils import mongo from pillar.api.utils import authorization, authentication, str2id, jsonify -from werkzeug import exceptions as wz_exceptions blueprint = Blueprint('service', __name__) log = logging.getLogger(__name__) @@ -70,16 +73,19 @@ def badger(): action, user_email, role, action, role) return 'Role not allowed', 403 - return do_badger(action, user_email, role) + return do_badger(action, role, user_email=user_email) -def do_badger(action, user_email, role): - """Performs a badger action, returning a HTTP response.""" +def do_badger(action: str, role: str, *, user_email: str='', user_id: bson.ObjectId=None): + """Performs a badger action, returning a HTTP response. + + Either user_email or user_id must be given. + """ if action not in {'grant', 'revoke'}: raise wz_exceptions.BadRequest('Action %r not supported' % action) - if not user_email: + if not user_email and user_id is None: raise wz_exceptions.BadRequest('User email not given') if not role: @@ -88,9 +94,14 @@ def do_badger(action, user_email, role): users_coll = current_app.data.driver.db['users'] # Fetch the user - db_user = users_coll.find_one({'email': user_email}, projection={'roles': 1, 'groups': 1}) + if user_email: + query = {'email': user_email} + else: + query = user_id + db_user = users_coll.find_one(query, projection={'roles': 1, 'groups': 1}) if db_user is None: - log.warning('badger(%s, %s, %s): user not found', action, user_email, role) + log.warning('badger(%s, %s, user_email=%s, user_id=%s): user not found', + action, role, user_email, user_id) return 'User not found', 404 # Apply the action diff --git a/pillar/auth/__init__.py b/pillar/auth/__init__.py index 72167a83..9bff9cdf 100644 --- a/pillar/auth/__init__.py +++ b/pillar/auth/__init__.py @@ -93,7 +93,10 @@ def get_blender_id_oauth_token(): def config_oauth_login(app): config = app.config if not config.get('SOCIAL_BLENDER_ID'): - log.info('OAuth Blender-ID login not setup.') + log.info('OAuth Blender-ID login not set up, no app config SOCIAL_BLENDER_ID.') + return None + if not config.get('BLENDER_ID_OAUTH_URL'): + log.error('Unable to use Blender ID, missing configuration BLENDER_ID_OAUTH_URL.') return None oauth = flask_oauthlib.client.OAuth(app) diff --git a/pillar/auth/subscriptions.py b/pillar/auth/subscriptions.py deleted file mode 100644 index 70c748ec..00000000 --- a/pillar/auth/subscriptions.py +++ /dev/null @@ -1,51 +0,0 @@ -"""Cloud subscription info. - -Connects to the external subscription server to obtain user info. -""" - -import logging - -from flask import current_app -import requests -from requests.adapters import HTTPAdapter - -log = logging.getLogger(__name__) - - -def fetch_user(email): - """Returns the user info dict from the external subscriptions management server. - - :returns: the store user info, or None if the user can't be found or there - was an error communicating. A dict like this is returned: - { - "shop_id": 700, - "cloud_access": 1, - "paid_balance": 314.75, - "balance_currency": "EUR", - "start_date": "2014-08-25 17:05:46", - "expiration_date": "2016-08-24 13:38:45", - "subscription_status": "wc-active", - "expiration_date_approximate": true - } - :rtype: dict - """ - - external_subscriptions_server = current_app.config['EXTERNAL_SUBSCRIPTIONS_MANAGEMENT_SERVER'] - - log.debug('Connecting to store at %s?blenderid=%s', external_subscriptions_server, email) - - # Retry a few times when contacting the store. - s = requests.Session() - s.mount(external_subscriptions_server, HTTPAdapter(max_retries=5)) - r = s.get(external_subscriptions_server, params={'blenderid': email}, - verify=current_app.config['TLS_CERT_FILE']) - - if r.status_code != 200: - log.warning("Error communicating with %s, code=%i, unable to check " - "subscription status of user %s", - external_subscriptions_server, r.status_code, email) - return None - - store_user = r.json() - return store_user - diff --git a/pillar/cli.py b/pillar/cli.py index 57bf2a96..c20f4825 100644 --- a/pillar/cli.py +++ b/pillar/cli.py @@ -306,7 +306,7 @@ def badger(action, user_email, role): with current_app.app_context(): service.fetch_role_to_group_id_map() - response, status = service.do_badger(action, user_email, role) + response, status = service.do_badger(action, role, user_email=user_email) if status == 204: log.info('Done.') diff --git a/pillar/tests/config_testing.py b/pillar/tests/config_testing.py index b27fcb1f..fa8ed776 100644 --- a/pillar/tests/config_testing.py +++ b/pillar/tests/config_testing.py @@ -12,3 +12,5 @@ ROLES_FOR_UNLIMITED_UPLOADS = {'subscriber', 'demo', 'admin'} GCLOUD_APP_CREDENTIALS = 'invalid-file-because-gcloud-storage-should-be-mocked-in-tests' STORAGE_BACKEND = 'local' + +EXTERNAL_SUBSCRIPTIONS_MANAGEMENT_SERVER = "http://store.localhost/api" diff --git a/pillar/web/users/routes.py b/pillar/web/users/routes.py index c882395a..fa0b45ce 100644 --- a/pillar/web/users/routes.py +++ b/pillar/web/users/routes.py @@ -1,8 +1,6 @@ import json import logging -import httplib2 # used by the oauth2 package import requests -import urllib.parse from flask import (abort, Blueprint, current_app, flash, redirect, render_template, request, session, url_for) @@ -10,8 +8,8 @@ from flask_login import login_required, logout_user, current_user from flask_oauthlib.client import OAuthException from werkzeug import exceptions as wz_exceptions +import pillar.api.blender_cloud.subscription import pillar.auth -from pillar.auth import subscriptions from pillar.web import system_util from .forms import UserProfileForm from .forms import UserSettingsEmailsForm @@ -46,6 +44,8 @@ def login(): @blueprint.route('/oauth/blender-id/authorized') def blender_id_authorized(): + from pillar.api.blender_cloud import subscription + check_oauth_provider(current_app.oauth_blender_id) try: oauth_resp = current_app.oauth_blender_id.authorized_response() @@ -70,7 +70,7 @@ def blender_id_authorized(): if current_user is not None: # Check with the store for user roles. If the user has an active # subscription, we apply the 'subscriber' role - user_roles_update(current_user.objectid) + subscription.update_subscription() next_after_login = session.get('next_after_login') if next_after_login: @@ -188,7 +188,7 @@ def settings_billing(): group = Group.find(group_id, api=api) groups.append(group.name) - store_user = subscriptions.fetch_user(user.email) + store_user = pillar.api.blender_cloud.subscription.fetch_subscription_info(user.email) return render_template( 'users/settings/billing.html', @@ -247,91 +247,3 @@ def users_index(): if not current_user.has_role('admin'): return abort(403) return render_template('users/index.html') - - -def user_roles_update(user_id): - """Update the user's roles based on the store subscription status and BlenderID roles.""" - - api = system_util.pillar_api() - group_subscriber = Group.find_one({'where': {'name': 'subscriber'}}, api=api) - group_demo = Group.find_one({'where': {'name': 'demo'}}, api=api) - - # Fetch the user once outside the loop, because we only need to get the - # subscription status once. - user = User.me(api=api) - - # Fetch user info from different sources. - store_user = subscriptions.fetch_user(user.email) or {} - bid_user = fetch_blenderid_user() - - grant_subscriber = store_user.get('cloud_access', 0) == 1 - grant_demo = bid_user.get('roles', {}).get('cloud_demo', False) - - max_retry = 5 - for retry_count in range(max_retry): - # Update the user's role & groups for their subscription status. - roles = set(user.roles or []) - groups = set(user.groups or []) - - if grant_subscriber: - roles.add('subscriber') - groups.add(group_subscriber._id) - elif 'admin' not in roles: - # Don't take away roles from admins. - roles.discard('subscriber') - groups.discard(group_subscriber._id) - - if grant_demo: - roles.add('demo') - groups.add(group_demo._id) - - # Only send an API request when the user has actually changed - if set(user.roles or []) == roles and set(user.groups or []) == groups: - break - - user.roles = list(roles) - user.groups = list(groups) - - try: - user.update(api=api) - except sdk_exceptions.PreconditionFailed: - log.warning('User etag changed while updating roles, retrying.') - else: - # Successful update, so we can stop the loop. - break - - # Fetch the user for the next iteration. - if retry_count < max_retry - 1: - user = User.me(api=api) - else: - log.warning('Tried %i times to update user %s, and failed each time. Giving up.', - max_retry, user_id) - - -def fetch_blenderid_user(): - """Returns the user info from BlenderID. - - Returns an empty dict if communication fails. - - :rtype: dict - """ - - bid_url = urllib.parse.urljoin(current_app.config['BLENDER_ID_ENDPOINT'], 'api/user') - log.debug('Fetching user info from %s', bid_url) - - try: - bid_resp = current_app.oauth_blender_id.get(bid_url) - except httplib2.HttpLib2Error: - log.exception('Error getting %s from BlenderID', bid_url) - return {} - - if bid_resp.status != 200: - log.warning('Error %i from BlenderID %s: %s', bid_resp.status, bid_url, bid_resp.data) - return {} - - if not bid_resp.data: - log.warning('Empty data returned from BlenderID %s', bid_url) - return {} - - log.debug('BlenderID returned %s', bid_resp.data) - return bid_resp.data diff --git a/tests/test_api/test_subscriptions.py b/tests/test_api/test_subscriptions.py new file mode 100644 index 00000000..6e8e69f2 --- /dev/null +++ b/tests/test_api/test_subscriptions.py @@ -0,0 +1,110 @@ +from unittest import mock + +import responses + +from pillar.tests import AbstractPillarTest, TEST_EMAIL_ADDRESS + + +# The OAuth lib doesn't use requests, so we can't use responses to mock it. +# Instead, we use unittest.mock for that. + + +class RoleUpdatingTest(AbstractPillarTest): + def setUp(self): + super().setUp() + + with self.app.test_request_context(): + self.create_standard_groups() + + def _setup_testcase(self, mocked_fetch_blenderid_user, *, + store_says_cloud_access: bool, + bid_says_cloud_demo: bool): + import urllib.parse + url = '%s?blenderid=%s' % (self.app.config['EXTERNAL_SUBSCRIPTIONS_MANAGEMENT_SERVER'], + urllib.parse.quote(TEST_EMAIL_ADDRESS)) + responses.add('GET', url, + json={'shop_id': 58432, + 'cloud_access': 1 if store_says_cloud_access else 0, + 'paid_balance': 0, + 'balance_currency': 'EUR', + 'start_date': '2017-05-04 12:07:49', + 'expiration_date': '2017-08-04 10:07:49', + 'subscription_status': 'wc-active' + }, + status=200, + match_querystring=True) + self.mock_blenderid_validate_happy() + mocked_fetch_blenderid_user.return_value = { + 'email': TEST_EMAIL_ADDRESS, + 'full_name': 'dr. Sybren A. St\u00fcvel', + 'id': 5555, + 'roles': { + 'admin': True, + 'bfct_trainer': False, + 'conference_speaker': True, + 'network_member': True + } + } + if bid_says_cloud_demo: + mocked_fetch_blenderid_user.return_value['roles']['cloud_demo'] = True + + @responses.activate + @mock.patch('pillar.api.blender_id.fetch_blenderid_user') + def test_store_api_role_grant_subscriber(self, mocked_fetch_blenderid_user): + self._setup_testcase(mocked_fetch_blenderid_user, + store_says_cloud_access=True, + bid_says_cloud_demo=False) + + self.get('/api/bcloud/update-subscription', auth_token='my-happy-token', + expected_status=204) + user_info = self.get('/api/users/me', auth_token='my-happy-token').json() + self.assertEqual(['subscriber'], user_info['roles']) + + @responses.activate + @mock.patch('pillar.api.blender_id.fetch_blenderid_user') + def test_store_api_role_revoke_subscriber(self, mocked_fetch_blenderid_user): + self._setup_testcase(mocked_fetch_blenderid_user, + store_says_cloud_access=False, + bid_says_cloud_demo=False) + + # Make sure this user is currently known as a subcriber. + self.create_user(roles={'subscriber'}, token='my-happy-token') + user_info = self.get('/api/users/me', auth_token='my-happy-token').json() + self.assertEqual(['subscriber'], user_info['roles']) + + # And after updating, it shouldn't be. + self.get('/api/bcloud/update-subscription', auth_token='my-happy-token', + expected_status=204) + user_info = self.get('/api/users/me', auth_token='my-happy-token').json() + self.assertEqual([], user_info['roles']) + + @responses.activate + @mock.patch('pillar.api.blender_id.fetch_blenderid_user') + def test_bid_api_grant_demo(self, mocked_fetch_blenderid_user): + self._setup_testcase(mocked_fetch_blenderid_user, + store_says_cloud_access=False, + bid_says_cloud_demo=True) + + self.get('/api/bcloud/update-subscription', auth_token='my-happy-token', + expected_status=204) + + user_info = self.get('/api/users/me', auth_token='my-happy-token').json() + self.assertEqual(['demo'], user_info['roles']) + + @responses.activate + @mock.patch('pillar.api.blender_id.fetch_blenderid_user') + def test_bid_api_role_revoke_subscriber(self, mocked_fetch_blenderid_user): + self._setup_testcase(mocked_fetch_blenderid_user, + store_says_cloud_access=False, + bid_says_cloud_demo=False) + + # Make sure this user is currently known as demo user. + self.create_user(roles={'demo'}, token='my-happy-token') + user_info = self.get('/api/users/me', auth_token='my-happy-token').json() + self.assertEqual(['demo'], user_info['roles']) + + # And after updating, it shouldn't be. + self.get('/api/bcloud/update-subscription', auth_token='my-happy-token', + expected_status=204) + user_info = self.get('/api/users/me', auth_token='my-happy-token').json() + self.assertEqual([], user_info['roles'])