Allow user creation from Blender ID webhook "user modified"
When the webhook indicates that the user has a Cloud subscription (demo, active, or renewable), the user is immediately created.
This commit is contained in:
@@ -13,6 +13,7 @@ import werkzeug.exceptions as wz_exceptions
|
|||||||
|
|
||||||
from pillar import current_app
|
from pillar import current_app
|
||||||
from pillar.api.blender_cloud import subscription
|
from pillar.api.blender_cloud import subscription
|
||||||
|
from pillar.api.utils.authentication import create_new_user_document, make_unique_username
|
||||||
from pillar.auth import UserClass
|
from pillar.auth import UserClass
|
||||||
|
|
||||||
blueprint = Blueprint('cloud-webhooks', __name__)
|
blueprint = Blueprint('cloud-webhooks', __name__)
|
||||||
@@ -76,24 +77,30 @@ def score(wh_payload: dict, user: dict) -> int:
|
|||||||
return match_on_bid * 10 + match_on_old_email + match_on_new_email * 2
|
return match_on_bid * 10 + match_on_old_email + match_on_new_email * 2
|
||||||
|
|
||||||
|
|
||||||
def fetch_user(wh_payload: dict) -> typing.Optional[dict]:
|
def insert_or_fetch_user(wh_payload: dict) -> typing.Optional[dict]:
|
||||||
"""Fetch the user from the DB
|
"""Fetch the user from the DB or create it.
|
||||||
|
|
||||||
:returns the user document, or None when not found.
|
Only creates it if the webhook payload indicates they could actually use
|
||||||
|
Blender Cloud (i.e. demo or subscriber). This prevents us from creating
|
||||||
|
Cloud accounts for Blender Network users.
|
||||||
|
|
||||||
|
:returns the user document, or None when not created.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
users_coll = current_app.db('users')
|
users_coll = current_app.db('users')
|
||||||
my_log = log.getChild('insert_or_fetch_user')
|
my_log = log.getChild('insert_or_fetch_user')
|
||||||
|
|
||||||
|
bid_str = str(wh_payload['id'])
|
||||||
|
email = wh_payload['email']
|
||||||
|
|
||||||
# Find the user by their Blender ID, or any of their email addresses.
|
# Find the user by their Blender ID, or any of their email addresses.
|
||||||
# We use one query to find all matching users. This is done as a
|
# We use one query to find all matching users. This is done as a
|
||||||
# consistency check; if more than one user is returned, we know the
|
# consistency check; if more than one user is returned, we know the
|
||||||
# database is inconsistent with Blender ID and can emit a warning
|
# database is inconsistent with Blender ID and can emit a warning
|
||||||
# about this.
|
# about this.
|
||||||
bid_str = str(wh_payload['id'])
|
|
||||||
query = {'$or': [
|
query = {'$or': [
|
||||||
{'auth.provider': 'blender-id', 'auth.user_id': bid_str},
|
{'auth.provider': 'blender-id', 'auth.user_id': bid_str},
|
||||||
{'email': {'$in': [wh_payload['old_email'], wh_payload['email']]}},
|
{'email': {'$in': [wh_payload['old_email'], email]}},
|
||||||
]}
|
]}
|
||||||
db_users = users_coll.find(query)
|
db_users = users_coll.find(query)
|
||||||
user_count = db_users.count()
|
user_count = db_users.count()
|
||||||
@@ -103,21 +110,50 @@ def fetch_user(wh_payload: dict) -> typing.Optional[dict]:
|
|||||||
calc_score = functools.partial(score, wh_payload)
|
calc_score = functools.partial(score, wh_payload)
|
||||||
best_score = max(db_users, key=calc_score)
|
best_score = max(db_users, key=calc_score)
|
||||||
|
|
||||||
my_log.warning('%d users found for query %s, picking %s',
|
my_log.error('%d users found for query %s, picking user %s (%s)',
|
||||||
user_count, query, best_score['email'])
|
user_count, query, best_score['_id'], best_score['email'])
|
||||||
return best_score
|
return best_score
|
||||||
if user_count:
|
if user_count:
|
||||||
db_user = db_users[0]
|
db_user = db_users[0]
|
||||||
my_log.debug('found user %s', db_user['email'])
|
my_log.debug('found user %s', db_user['email'])
|
||||||
return db_user
|
return db_user
|
||||||
|
|
||||||
my_log.info('Received update for unknown user %r', wh_payload['old_email'])
|
# Pretend to create the user, so that we can inspect the resulting
|
||||||
|
# capabilities. This is more future-proof than looking at the list
|
||||||
|
# of roles in the webhook payload.
|
||||||
|
username = make_unique_username(email)
|
||||||
|
user_doc = create_new_user_document(email, bid_str, username,
|
||||||
|
provider='blender-id',
|
||||||
|
full_name=wh_payload['full_name'])
|
||||||
|
|
||||||
|
user_doc['roles'] = [subscription.ROLES_BID_TO_PILLAR[r]
|
||||||
|
for r in wh_payload.get('roles', [])
|
||||||
|
if r in subscription.ROLES_BID_TO_PILLAR]
|
||||||
|
|
||||||
|
user_ob = UserClass.construct('', user_doc)
|
||||||
|
create = user_ob.has_cap('subscriber') or user_ob.has_cap('can-renew-subscription')
|
||||||
|
if not create:
|
||||||
|
my_log.info('Received update for unknown user %r without Cloud access (caps=%s)',
|
||||||
|
wh_payload['old_email'], user_ob.capabilities)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
# Actually create the user in the database.
|
||||||
|
r, _, _, status = current_app.post_internal('users', user_doc)
|
||||||
|
if status != 201:
|
||||||
|
my_log.error('unable to create user %s: : %r %r', email, status, r)
|
||||||
|
raise wz_exceptions.InternalServerError('unable to create user')
|
||||||
|
|
||||||
|
user_doc.update(r)
|
||||||
|
my_log.info('created user %r = %s to allow immediate Cloud access', email, user_doc['_id'])
|
||||||
|
return user_doc
|
||||||
|
|
||||||
|
|
||||||
@blueprint.route('/user-modified', methods=['POST'])
|
@blueprint.route('/user-modified', methods=['POST'])
|
||||||
def user_modified():
|
def user_modified():
|
||||||
"""Updates the local user based on the info from Blender ID.
|
"""Update the local user based on the info from Blender ID.
|
||||||
|
|
||||||
|
If the payload indicates the user has access to Blender Cloud (or at least
|
||||||
|
a renewable subscription), create the user if not already in our DB.
|
||||||
|
|
||||||
The payload we expect is a dictionary like:
|
The payload we expect is a dictionary like:
|
||||||
{'id': 12345, # the user's ID in Blender ID
|
{'id': 12345, # the user's ID in Blender ID
|
||||||
@@ -135,7 +171,7 @@ def user_modified():
|
|||||||
my_log.info('payload: %s', payload)
|
my_log.info('payload: %s', payload)
|
||||||
|
|
||||||
# Update the user
|
# Update the user
|
||||||
db_user = fetch_user(payload)
|
db_user = insert_or_fetch_user(payload)
|
||||||
if not db_user:
|
if not db_user:
|
||||||
my_log.info('Received update for unknown user %r', payload['old_email'])
|
my_log.info('Received update for unknown user %r', payload['old_email'])
|
||||||
return '', 204
|
return '', 204
|
||||||
|
@@ -4,16 +4,19 @@ import json
|
|||||||
from abstract_cloud_test import AbstractCloudTest
|
from abstract_cloud_test import AbstractCloudTest
|
||||||
|
|
||||||
|
|
||||||
class UserModifiedTest(AbstractCloudTest):
|
class AbstractWebhookTest(AbstractCloudTest):
|
||||||
def setUp(self, **kwargs):
|
def setUp(self, **kwargs):
|
||||||
super().setUp(**kwargs)
|
super().setUp(**kwargs)
|
||||||
self.enter_app_context()
|
self.enter_app_context()
|
||||||
|
self.create_standard_groups()
|
||||||
self.hmac_secret = b'1234 je moeder'
|
self.hmac_secret = b'1234 je moeder'
|
||||||
self.app.config['BLENDER_ID_WEBHOOK_USER_CHANGED_SECRET'] = self.hmac_secret.decode()
|
self.app.config['BLENDER_ID_WEBHOOK_USER_CHANGED_SECRET'] = self.hmac_secret.decode()
|
||||||
self.uid = self.create_user(24 * 'a',
|
self.uid = self.create_user(24 * 'a',
|
||||||
roles={'subscriber'},
|
roles={'subscriber'},
|
||||||
email='old@email.address')
|
email='old@email.address')
|
||||||
|
|
||||||
|
|
||||||
|
class UserModifiedTest(AbstractWebhookTest):
|
||||||
def test_change_full_name(self):
|
def test_change_full_name(self):
|
||||||
payload = {'id': 1112333,
|
payload = {'id': 1112333,
|
||||||
'old_email': 'old@email.address',
|
'old_email': 'old@email.address',
|
||||||
@@ -128,6 +131,45 @@ class UserModifiedTest(AbstractCloudTest):
|
|||||||
self.assertEqual('ကြယ်ဆွတ်', db_user['full_name'])
|
self.assertEqual('ကြယ်ဆွတ်', db_user['full_name'])
|
||||||
self.assertEqual(['demo'], db_user['roles'])
|
self.assertEqual(['demo'], db_user['roles'])
|
||||||
|
|
||||||
|
def test_multiple_users_matching(self):
|
||||||
|
users_coll = self.app.db('users')
|
||||||
|
users_coll.update_one({'_id': self.uid},
|
||||||
|
{'$set': {'auth': [
|
||||||
|
{'provider': 'mastodon', 'user_id': 'hey@there'},
|
||||||
|
{'provider': 'blender-id', 'user_id': '1112333'}
|
||||||
|
]}})
|
||||||
|
|
||||||
|
# Create another user with email=new@elsewhere.address
|
||||||
|
other_uid = self.create_user(24 * 'b', email='new@elsewhere.address')
|
||||||
|
|
||||||
|
payload = {'id': 1112333,
|
||||||
|
'old_email': 'new@elsewhere.address',
|
||||||
|
'full_name': 'ကြယ်ဆွတ်',
|
||||||
|
'email': 'new@elsewhere.address',
|
||||||
|
'roles': ['cloud_demo']}
|
||||||
|
as_json = json.dumps(payload).encode()
|
||||||
|
mac = hmac.new(self.hmac_secret,
|
||||||
|
as_json, hashlib.sha256)
|
||||||
|
self.post('/api/webhooks/user-modified',
|
||||||
|
data=as_json,
|
||||||
|
content_type='application/json',
|
||||||
|
headers={'X-Webhook-HMAC': mac.hexdigest()},
|
||||||
|
expected_status=204)
|
||||||
|
|
||||||
|
# Check the effect on the user
|
||||||
|
db_user = self.fetch_user_from_db(self.uid)
|
||||||
|
self.assertEqual('new@elsewhere.address', db_user['email'])
|
||||||
|
self.assertEqual('ကြယ်ဆွတ်', db_user['full_name'])
|
||||||
|
self.assertEqual(['demo'], db_user['roles'])
|
||||||
|
|
||||||
|
# The other user with the email address should still be there.
|
||||||
|
# This *will* cause problems later, so the code should log this
|
||||||
|
# as error condition!
|
||||||
|
other_user = self.fetch_user_from_db(other_uid)
|
||||||
|
self.assertEqual('new@elsewhere.address', other_user['email'])
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
def test_change_roles(self):
|
def test_change_roles(self):
|
||||||
payload = {'id': 1112333,
|
payload = {'id': 1112333,
|
||||||
'old_email': 'old@email.address',
|
'old_email': 'old@email.address',
|
||||||
@@ -218,6 +260,21 @@ class UserModifiedTest(AbstractCloudTest):
|
|||||||
self.assertEqual('คนรักของผัดไทย', db_user['full_name'])
|
self.assertEqual('คนรักของผัดไทย', db_user['full_name'])
|
||||||
self.assertEqual({'subscriber'}, set(db_user['roles']))
|
self.assertEqual({'subscriber'}, set(db_user['roles']))
|
||||||
|
|
||||||
|
def test_text_plain(self):
|
||||||
|
payload = b'{"valid": false}'
|
||||||
|
mac = hmac.new(self.hmac_secret, payload, hashlib.sha256)
|
||||||
|
self.post('/api/webhooks/user-modified',
|
||||||
|
data=payload,
|
||||||
|
content_type='text/plain',
|
||||||
|
headers={'X-Webhook-HMAC': mac.hexdigest()},
|
||||||
|
expected_status=400)
|
||||||
|
|
||||||
|
# Check the effect on the user
|
||||||
|
db_user = self.fetch_user_from_db(self.uid)
|
||||||
|
self.assertEqual('old@email.address', db_user['email'])
|
||||||
|
self.assertEqual('คนรักของผัดไทย', db_user['full_name'])
|
||||||
|
self.assertEqual({'subscriber'}, set(db_user['roles']))
|
||||||
|
|
||||||
|
|
||||||
class UserScoreTest(AbstractCloudTest):
|
class UserScoreTest(AbstractCloudTest):
|
||||||
def setUp(self, **kwargs):
|
def setUp(self, **kwargs):
|
||||||
@@ -254,3 +311,144 @@ class UserScoreTest(AbstractCloudTest):
|
|||||||
],
|
],
|
||||||
'email': 'new@email.address'
|
'email': 'new@email.address'
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
|
||||||
|
class UserModifiedUserCreationTest(AbstractWebhookTest):
|
||||||
|
def test_unknown_email(self):
|
||||||
|
payload = {'id': 1112333,
|
||||||
|
'old_email': 'unknown@email.address',
|
||||||
|
'full_name': 'ကြယ်ဆွတ်',
|
||||||
|
'email': 'new@email.address',
|
||||||
|
'roles': ['cloud_demo']}
|
||||||
|
as_json = json.dumps(payload).encode()
|
||||||
|
mac = hmac.new(self.hmac_secret,
|
||||||
|
as_json, hashlib.sha256)
|
||||||
|
self.post('/api/webhooks/user-modified',
|
||||||
|
data=as_json,
|
||||||
|
content_type='application/json',
|
||||||
|
headers={'X-Webhook-HMAC': mac.hexdigest()},
|
||||||
|
expected_status=204)
|
||||||
|
|
||||||
|
# Check that the user has been created, and the existing user has not been touched.
|
||||||
|
db_user = self.fetch_user_from_db(self.uid)
|
||||||
|
self.assertEqual('old@email.address', db_user['email'])
|
||||||
|
self.assertEqual('คนรักของผัดไทย', db_user['full_name'])
|
||||||
|
self.assertEqual({'subscriber'}, set(db_user['roles']))
|
||||||
|
|
||||||
|
users_coll = self.app.db('users')
|
||||||
|
new_user = users_coll.find_one({'email': 'new@email.address'})
|
||||||
|
self.assertIsNotNone(new_user)
|
||||||
|
self.assertEqual('ကြယ်ဆွတ်', new_user['full_name'])
|
||||||
|
self.assertEqual('new', new_user['username']) # based on email address
|
||||||
|
self.assertEqual(['demo'], new_user['roles'])
|
||||||
|
self.assertEqual({
|
||||||
|
'provider': 'blender-id',
|
||||||
|
'user_id': '1112333',
|
||||||
|
'token': '',
|
||||||
|
}, new_user['auth'][0])
|
||||||
|
|
||||||
|
def test_create_subscriber(self):
|
||||||
|
payload = {'id': 1112333,
|
||||||
|
'old_email': 'unknown@email.address',
|
||||||
|
'full_name': 'ကြယ်ဆွတ်',
|
||||||
|
'email': 'new@email.address',
|
||||||
|
'roles': ['cloud_subscriber', 'cloud_has_subscription']}
|
||||||
|
as_json = json.dumps(payload).encode()
|
||||||
|
mac = hmac.new(self.hmac_secret,
|
||||||
|
as_json, hashlib.sha256)
|
||||||
|
self.post('/api/webhooks/user-modified',
|
||||||
|
data=as_json,
|
||||||
|
content_type='application/json',
|
||||||
|
headers={'X-Webhook-HMAC': mac.hexdigest()},
|
||||||
|
expected_status=204)
|
||||||
|
|
||||||
|
# Check that the user has been created, and the existing user has not been touched.
|
||||||
|
db_user = self.fetch_user_from_db(self.uid)
|
||||||
|
self.assertEqual('old@email.address', db_user['email'])
|
||||||
|
self.assertEqual('คนรักของผัดไทย', db_user['full_name'])
|
||||||
|
self.assertEqual({'subscriber'}, set(db_user['roles']))
|
||||||
|
|
||||||
|
users_coll = self.app.db('users')
|
||||||
|
new_user = users_coll.find_one({'email': 'new@email.address'})
|
||||||
|
self.assertIsNotNone(new_user)
|
||||||
|
self.assertEqual('new', new_user['username'])
|
||||||
|
self.assertEqual('ကြယ်ဆွတ်', new_user['full_name'])
|
||||||
|
self.assertEqual(['subscriber', 'has_subscription'], new_user['roles'])
|
||||||
|
|
||||||
|
def test_create_renewable(self):
|
||||||
|
payload = {'id': 1112333,
|
||||||
|
'old_email': 'unknown@email.address',
|
||||||
|
'full_name': 'ကြယ်ဆွတ်',
|
||||||
|
'email': 'new@email.address',
|
||||||
|
'roles': ['cloud_has_subscription']}
|
||||||
|
as_json = json.dumps(payload).encode()
|
||||||
|
mac = hmac.new(self.hmac_secret,
|
||||||
|
as_json, hashlib.sha256)
|
||||||
|
self.post('/api/webhooks/user-modified',
|
||||||
|
data=as_json,
|
||||||
|
content_type='application/json',
|
||||||
|
headers={'X-Webhook-HMAC': mac.hexdigest()},
|
||||||
|
expected_status=204)
|
||||||
|
|
||||||
|
# Check that the user has been created, and the existing user has not been touched.
|
||||||
|
db_user = self.fetch_user_from_db(self.uid)
|
||||||
|
self.assertEqual('old@email.address', db_user['email'])
|
||||||
|
self.assertEqual('คนรักของผัดไทย', db_user['full_name'])
|
||||||
|
self.assertEqual({'subscriber'}, set(db_user['roles']))
|
||||||
|
|
||||||
|
users_coll = self.app.db('users')
|
||||||
|
new_user = users_coll.find_one({'email': 'new@email.address'})
|
||||||
|
self.assertIsNotNone(new_user)
|
||||||
|
self.assertEqual('new', new_user['username'])
|
||||||
|
self.assertEqual('ကြယ်ဆွတ်', new_user['full_name'])
|
||||||
|
self.assertEqual(['has_subscription'], new_user['roles'])
|
||||||
|
|
||||||
|
def test_no_full_name(self):
|
||||||
|
"""Blender ID doesn't enforce full names on creation."""
|
||||||
|
payload = {'id': 1112333,
|
||||||
|
'old_email': 'unknown@email.address',
|
||||||
|
'full_name': '',
|
||||||
|
'email': 'new@email.address',
|
||||||
|
'roles': ['cloud_subscriber', 'cloud_has_subscription']}
|
||||||
|
as_json = json.dumps(payload).encode()
|
||||||
|
mac = hmac.new(self.hmac_secret,
|
||||||
|
as_json, hashlib.sha256)
|
||||||
|
self.post('/api/webhooks/user-modified',
|
||||||
|
data=as_json,
|
||||||
|
content_type='application/json',
|
||||||
|
headers={'X-Webhook-HMAC': mac.hexdigest()},
|
||||||
|
expected_status=204)
|
||||||
|
|
||||||
|
# Check that the user has been created correctly.
|
||||||
|
users_coll = self.app.db('users')
|
||||||
|
new_user = users_coll.find_one({'email': 'new@email.address'})
|
||||||
|
self.assertIsNotNone(new_user)
|
||||||
|
self.assertEqual('new', new_user['username'])
|
||||||
|
self.assertEqual('new', new_user['full_name']) # defaults to username
|
||||||
|
self.assertEqual(['subscriber', 'has_subscription'], new_user['roles'])
|
||||||
|
|
||||||
|
def test_no_create_when_not_subscriber(self):
|
||||||
|
"""Don't create local users when they are not subscriber."""
|
||||||
|
payload = {'id': 1112333,
|
||||||
|
'old_email': 'unknown@email.address',
|
||||||
|
'full_name': 'ကြယ်ဆွတ်',
|
||||||
|
'email': 'new@email.address',
|
||||||
|
'roles': ['blender_network']}
|
||||||
|
as_json = json.dumps(payload).encode()
|
||||||
|
mac = hmac.new(self.hmac_secret,
|
||||||
|
as_json, hashlib.sha256)
|
||||||
|
self.post('/api/webhooks/user-modified',
|
||||||
|
data=as_json,
|
||||||
|
content_type='application/json',
|
||||||
|
headers={'X-Webhook-HMAC': mac.hexdigest()},
|
||||||
|
expected_status=204)
|
||||||
|
|
||||||
|
# Check that the user has been not been created, and the existing user has not been touched.
|
||||||
|
db_user = self.fetch_user_from_db(self.uid)
|
||||||
|
self.assertEqual('old@email.address', db_user['email'])
|
||||||
|
self.assertEqual('คนรักของผัดไทย', db_user['full_name'])
|
||||||
|
self.assertEqual({'subscriber'}, set(db_user['roles']))
|
||||||
|
|
||||||
|
users_coll = self.app.db('users')
|
||||||
|
new_user = users_coll.find_one({'email': 'new@email.address'})
|
||||||
|
self.assertIsNone(new_user)
|
||||||
|
Reference in New Issue
Block a user