Use Blender ID to obtain subscription status.

Instead of performing a call to the Blender Store, call to Blender ID to
get the user's subscription status.

Currently this is performed as a second HTTP call after logging in; in the
future we may want to include the roles in the login response from Blender
ID, so that we can do this in one call instead of two.
This commit is contained in:
Sybren A. Stüvel 2017-11-30 15:28:35 +01:00
parent 8ba4cc5c0c
commit 0b218eb656
5 changed files with 82 additions and 131 deletions

View File

@ -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
# 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',
}
"""
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
@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):

View File

@ -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):

View File

@ -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()

View File

@ -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)

View File

@ -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')