diff --git a/pillar/api/blender_cloud/subscription.py b/pillar/api/blender_cloud/subscription.py index 9e5aacb9..ea93b393 100644 --- a/pillar/api/blender_cloud/subscription.py +++ b/pillar/api/blender_cloud/subscription.py @@ -1,94 +1,37 @@ +import collections import logging import typing -from flask import current_app, Blueprint +from flask import Blueprint +from pillar.auth import UserClass 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 - import requests.exceptions - - 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)) - - try: - r = s.get(external_subscriptions_server, - params={'blenderid': email}, - verify=current_app.config['TLS_CERT_FILE'], - timeout=current_app.config.get('EXTERNAL_SUBSCRIPTIONS_TIMEOUT_SECS', 10)) - except requests.exceptions.ConnectionError as ex: - log.error('Error connecting to %s: %s', external_subscriptions_server, ex) - return None - except requests.exceptions.Timeout as ex: - log.error('Timeout communicating with %s: %s', external_subscriptions_server, ex) - return None - except requests.exceptions.RequestException as ex: - log.error('Some error communicating with %s: %s', external_subscriptions_server, ex) - return None - - 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 +# Mapping from roles on Blender ID to roles here in Pillar. +# Roles not mentioned here will not be synced from Blender ID. +ROLES_BID_TO_PILLAR = { + 'cloud_subscriber': 'subscriber', + 'cloud_demo': 'demo', + 'cloud_has_subscription': 'has_subscription', +} @blueprint.route('/update-subscription') @authorization.require_login() -def update_subscription(): +def update_subscription() -> typing.Tuple[str, int]: """Updates the subscription status of the current user. Returns an empty HTTP response. """ - import pprint from pillar import auth - from pillar.api import blender_id, service - from pillar.api.utils import authentication + from pillar.api import blender_id my_log: logging.Logger = log.getChild('update_subscription') - user_id = authentication.current_user_id() + current_user = auth.get_current_user() try: bid_user = blender_id.fetch_blenderid_user() @@ -98,46 +41,60 @@ def update_subscription(): if not bid_user: my_log.warning('Logged in user %s has no BlenderID account! ' - 'Unable to update subscription status.', user_id) + 'Unable to update subscription status.', current_user.user_id) return '', 204 - # 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). + do_update_subscription(current_user, bid_user) + return '', 204 + + +def do_update_subscription(local_user: UserClass, bid_user: dict): + """Updates the subscription status of the user given the Blender ID user info. + + Uses the badger service to update the user's roles from Blender ID. + + bid_user should be a dict like: + {'id': 1234, + 'full_name': 'मूंगफली मक्खन प्रेमी', + 'email': 'here@example.com', + 'roles': {'cloud_demo': True}} + """ + + from pillar.api import service + + my_log: logging.Logger = log.getChild('do_update_subscription') + 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 'Internal error', 500 - store_user = fetch_subscription_info(email) or {} + email = '-missing email-' # 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) + bid_roles = collections.defaultdict(bool, **bid_user.get('roles', {})) + plr_roles = set(local_user.roles) - is_subscriber = authorization.user_has_role('subscriber') - is_demo = authorization.user_has_role('demo') + grant_roles = set() + revoke_roles = set() + for bid_role, plr_role in ROLES_BID_TO_PILLAR.items(): + if bid_roles[bid_role] and plr_role not in plr_roles: + grant_roles.add(plr_role) + continue + if not bid_roles[bid_role] and plr_role in plr_roles: + revoke_roles.add(plr_role) - if grant_subscriber != is_subscriber: - action = 'grant' if grant_subscriber else 'revoke' - my_log.info('%sing subscriber role to user %s (Blender ID email %s)', - action, user_id, email) - service.do_badger(action, role='subscriber', user_id=user_id) - else: - my_log.debug('Not changing subscriber role, grant=%r and is=%s', - grant_subscriber, is_subscriber) + user_id = local_user.user_id - if grant_demo != is_demo: - action = 'grant' if grant_demo else 'revoke' - my_log.info('%sing demo role to user %s (Blender ID email %s)', action, user_id, email) - service.do_badger(action, role='demo', user_id=user_id) - else: - my_log.debug('Not changing demo role, grant=%r and is=%s', - grant_demo, is_demo) + if grant_roles: + if my_log.isEnabledFor(logging.INFO): + my_log.info('granting roles to user %s (Blender ID %s): %s', + user_id, email, ', '.join(sorted(grant_roles))) + service.do_badger('grant', roles=grant_roles, user_id=user_id) - return '', 204 + if revoke_roles: + if my_log.isEnabledFor(logging.INFO): + my_log.info('revoking roles to user %s (Blender ID %s): %s', + user_id, email, ', '.join(sorted(revoke_roles))) + service.do_badger('revoke', roles=revoke_roles, user_id=user_id) def setup_app(app, url_prefix): diff --git a/pillar/api/blender_id.py b/pillar/api/blender_id.py index 06c8cae2..8d025243 100644 --- a/pillar/api/blender_id.py +++ b/pillar/api/blender_id.py @@ -181,7 +181,8 @@ def fetch_blenderid_user() -> dict: "roles": { "admin": true, "bfct_trainer": false, - "cloud_single_member": true, + "cloud_has_subscription": true, + "cloud_subscriber": true, "conference_speaker": true, "network_member": true } @@ -218,12 +219,13 @@ def fetch_blenderid_user() -> dict: log.warning('Error %i from BlenderID %s: %s', bid_resp.status_code, bid_url, bid_resp.text) return {} - if not bid_resp.json(): + payload = bid_resp.json() + if not payload: log.warning('Empty data returned from BlenderID %s', bid_url) return {} - log.debug('BlenderID returned %s', bid_resp.json()) - return bid_resp.json() + log.debug('BlenderID returned %s', payload) + return payload def setup_app(app, url_prefix): diff --git a/pillar/config.py b/pillar/config.py index 526ae4a6..e5938499 100644 --- a/pillar/config.py +++ b/pillar/config.py @@ -99,6 +99,11 @@ FULL_FILE_ACCESS_ROLES = {'admin', 'subscriber', 'demo'} BLENDER_ID_CLIENT_ID = 'SPECIAL-SNOWFLAKE-57' BLENDER_ID_SUBCLIENT_ID = 'PILLAR' +# Blender ID user info API endpoint URL and auth token, only used for +# reconciling subscribers. The token requires the 'userinfo' scope. +BLENDER_ID_USER_INFO_API = 'http://blender-id:8000/api/user/' +BLENDER_ID_USER_INFO_TOKEN = '-set-in-config-local-' + # Collection of supported OAuth providers (Blender ID, Facebook and Google). # Example entry: # OAUTH_CREDENTIALS = { @@ -173,9 +178,6 @@ URLER_SERVICE_AUTH_TOKEN = None # front-end. BLENDER_CLOUD_ADDON_VERSION = '1.4' -EXTERNAL_SUBSCRIPTIONS_MANAGEMENT_SERVER = 'https://store.blender.org/api/' -EXTERNAL_SUBSCRIPTIONS_TIMEOUT_SECS = 10 - # Certificate file for communication with other systems. TLS_CERT_FILE = requests.certs.where() diff --git a/pillar/web/users/routes.py b/pillar/web/users/routes.py index 85aa0462..713e5ffc 100644 --- a/pillar/web/users/routes.py +++ b/pillar/web/users/routes.py @@ -69,8 +69,7 @@ def oauth_callback(provider): pillar.auth.login_user(token['token'], load_from_db=True) if provider == 'blender-id' and current_user.is_authenticated: - # Check with the store for user roles. If the user has an active subscription, we apply - # the 'subscriber' role + # Check with Blender ID to update certain user roles. update_subscription() next_after_login = session.pop('next_after_login', None) diff --git a/tests/test_api/test_subscriptions.py b/tests/test_api/test_subscriptions.py index 6e8e69f2..08dec044 100644 --- a/tests/test_api/test_subscriptions.py +++ b/tests/test_api/test_subscriptions.py @@ -1,3 +1,4 @@ +import typing from unittest import mock import responses @@ -17,22 +18,16 @@ class RoleUpdatingTest(AbstractPillarTest): self.create_standard_groups() def _setup_testcase(self, mocked_fetch_blenderid_user, *, - store_says_cloud_access: bool, - bid_says_cloud_demo: bool): + bid_roles: typing.Set[str]): import urllib.parse + + # The Store API endpoint should not be called upon any more. 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, + status=500, match_querystring=True) + self.mock_blenderid_validate_happy() mocked_fetch_blenderid_user.return_value = { 'email': TEST_EMAIL_ADDRESS, @@ -45,27 +40,25 @@ class RoleUpdatingTest(AbstractPillarTest): 'network_member': True } } - if bid_says_cloud_demo: - mocked_fetch_blenderid_user.return_value['roles']['cloud_demo'] = True + for role in bid_roles: + mocked_fetch_blenderid_user.return_value['roles'][role] = 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) + bid_roles={'cloud_subscriber', 'cloud_has_subscription'}) 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']) + self.assertEqual({'subscriber', 'has_subscription'}, set(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) + bid_roles={'conference_speaker'}) # Make sure this user is currently known as a subcriber. self.create_user(roles={'subscriber'}, token='my-happy-token') @@ -82,8 +75,7 @@ class RoleUpdatingTest(AbstractPillarTest): @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) + bid_roles={'cloud_demo'}) self.get('/api/bcloud/update-subscription', auth_token='my-happy-token', expected_status=204) @@ -93,10 +85,9 @@ class RoleUpdatingTest(AbstractPillarTest): @responses.activate @mock.patch('pillar.api.blender_id.fetch_blenderid_user') - def test_bid_api_role_revoke_subscriber(self, mocked_fetch_blenderid_user): + def test_bid_api_role_revoke_demo(self, mocked_fetch_blenderid_user): self._setup_testcase(mocked_fetch_blenderid_user, - store_says_cloud_access=False, - bid_says_cloud_demo=False) + bid_roles={'conference_speaker'}) # Make sure this user is currently known as demo user. self.create_user(roles={'demo'}, token='my-happy-token')