Allow service accounts to be email-less
This removes the ability of updating service accounts through the CLI (something we never used anyway), now that service accounts cannot be uniquely identified by their email address.
This commit is contained in:
@@ -1,7 +1,9 @@
|
||||
"""Service accounts."""
|
||||
|
||||
import logging
|
||||
import typing
|
||||
|
||||
import bson
|
||||
import blinker
|
||||
import bson
|
||||
|
||||
@@ -22,6 +24,10 @@ ROLES_WITH_GROUPS = {'admin', 'demo', 'subscriber'}
|
||||
role_to_group_id = {}
|
||||
|
||||
|
||||
class ServiceAccountCreationError(Exception):
|
||||
"""Raised when a service account cannot be created."""
|
||||
|
||||
|
||||
@blueprint.before_app_first_request
|
||||
def fetch_role_to_group_id_map():
|
||||
"""Fills the _role_to_group_id mapping upon application startup."""
|
||||
@@ -173,62 +179,35 @@ def manage_user_group_membership(db_user, role, action):
|
||||
return user_groups
|
||||
|
||||
|
||||
def create_service_account(email, roles, service, update_existing=None):
|
||||
def create_service_account(email: str, roles: typing.Iterable, service: dict):
|
||||
"""Creates a service account with the given roles + the role 'service'.
|
||||
|
||||
:param email: email address associated with the account
|
||||
:type email: str
|
||||
:param email: optional email address associated with the account.
|
||||
:param roles: iterable of role names
|
||||
:param service: dict of the 'service' key in the user.
|
||||
:type service: dict
|
||||
:param update_existing: callback function that receives an existing user to update
|
||||
for this service, in case the email address is already in use by someone.
|
||||
If not given or None, updating existing users is disallowed, and a ValueError
|
||||
exception is thrown instead.
|
||||
|
||||
:return: tuple (user doc, token doc)
|
||||
"""
|
||||
|
||||
from pillar.api.utils import remove_private_keys
|
||||
# Create a user with the correct roles.
|
||||
roles = sorted(set(roles).union({'service'}))
|
||||
user_id = bson.ObjectId()
|
||||
|
||||
# Find existing
|
||||
users_coll = current_app.db()['users']
|
||||
user = users_coll.find_one({'email': email})
|
||||
if user:
|
||||
# Check whether updating is allowed at all.
|
||||
if update_existing is None:
|
||||
raise ValueError('User %s already exists' % email)
|
||||
log.info('Creating service account %s with roles %s', user_id, roles)
|
||||
user = {'_id': user_id,
|
||||
'username': f'SRV-{user_id}',
|
||||
'groups': [],
|
||||
'roles': roles,
|
||||
'settings': {'email_communications': 0},
|
||||
'auth': [],
|
||||
'full_name': f'SRV-{user_id}',
|
||||
'service': service}
|
||||
if email:
|
||||
user['email'] = email
|
||||
result, _, _, status = current_app.post_internal('users', user)
|
||||
|
||||
# Compute the new roles, and assign.
|
||||
roles = list(set(roles).union({'service'}).union(user['roles']))
|
||||
user['roles'] = list(roles)
|
||||
|
||||
# Let the caller perform any required updates.
|
||||
log.info('Updating existing user %s to become service account for %s',
|
||||
email, roles)
|
||||
update_existing(user['service'])
|
||||
|
||||
# Try to store the updated user.
|
||||
result, _, _, status = current_app.put_internal('users',
|
||||
remove_private_keys(user),
|
||||
_id=user['_id'])
|
||||
expected_status = 200
|
||||
else:
|
||||
# Create a user with the correct roles.
|
||||
roles = list(set(roles).union({'service'}))
|
||||
user = {'username': email,
|
||||
'groups': [],
|
||||
'roles': roles,
|
||||
'settings': {'email_communications': 0},
|
||||
'auth': [],
|
||||
'full_name': email,
|
||||
'email': email,
|
||||
'service': service}
|
||||
result, _, _, status = current_app.post_internal('users', user)
|
||||
expected_status = 201
|
||||
|
||||
if status != expected_status:
|
||||
raise SystemExit('Error creating user {}: {}'.format(email, result))
|
||||
if status != 201:
|
||||
raise ServiceAccountCreationError('Error creating user {}: {}'.format(user_id, result))
|
||||
user.update(result)
|
||||
|
||||
# Create an authentication token that won't expire for a long time.
|
||||
|
@@ -5,13 +5,14 @@ from eve.utils import parse_request
|
||||
from flask import current_app, g
|
||||
from pillar.api.users.routes import log
|
||||
from pillar.api.utils.authorization import user_has_role
|
||||
from werkzeug.exceptions import Forbidden
|
||||
from werkzeug import exceptions as wz_exceptions
|
||||
|
||||
USER_EDITABLE_FIELDS = {'full_name', 'username', 'email', 'settings'}
|
||||
|
||||
# These fields nobody is allowed to touch directly, not even admins.
|
||||
USER_ALWAYS_RESTORE_FIELDS = {'auth'}
|
||||
|
||||
|
||||
def before_replacing_user(request, lookup):
|
||||
"""Prevents changes to any field of the user doc, except USER_EDITABLE_FIELDS."""
|
||||
|
||||
@@ -52,6 +53,11 @@ def before_replacing_user(request, lookup):
|
||||
else:
|
||||
del put_data[db_key]
|
||||
|
||||
# Regular users should always have an email address
|
||||
if 'service' not in put_data['roles']:
|
||||
if not put_data.get('email'):
|
||||
raise wz_exceptions.UnprocessableEntity('email field must be given')
|
||||
|
||||
|
||||
def push_updated_user_to_algolia(user, original):
|
||||
"""Push an update to the Algolia index when a user item is updated"""
|
||||
@@ -91,7 +97,7 @@ def check_user_access(request, lookup):
|
||||
return
|
||||
|
||||
if not lookup and not current_user:
|
||||
raise Forbidden()
|
||||
raise wz_exceptions.Forbidden()
|
||||
|
||||
# Add a filter to only return the current user.
|
||||
if '_id' not in lookup:
|
||||
@@ -106,10 +112,10 @@ def check_put_access(request, lookup):
|
||||
|
||||
current_user = g.get('current_user')
|
||||
if not current_user:
|
||||
raise Forbidden()
|
||||
raise wz_exceptions.Forbidden()
|
||||
|
||||
if str(lookup['_id']) != str(current_user['user_id']):
|
||||
raise Forbidden()
|
||||
raise wz_exceptions.Forbidden()
|
||||
|
||||
|
||||
def after_fetching_user(user):
|
||||
|
@@ -315,15 +315,14 @@ def badger(action, user_email, role):
|
||||
log.info('Status : %i', status)
|
||||
|
||||
|
||||
def create_service_account(email, service_roles, service_definition, update_existing=None):
|
||||
def create_service_account(email, service_roles, service_definition):
|
||||
from pillar.api import service
|
||||
from pillar.api.utils import dumps
|
||||
|
||||
account, token = service.create_service_account(
|
||||
email,
|
||||
service_roles,
|
||||
service_definition,
|
||||
update_existing=update_existing
|
||||
service_definition
|
||||
)
|
||||
|
||||
print('Service account information:')
|
||||
|
@@ -449,6 +449,25 @@ class AbstractPillarTest(TestMinimal):
|
||||
def patch(self, *args, **kwargs):
|
||||
return self.client_request('PATCH', *args, **kwargs)
|
||||
|
||||
def assertAllowsAccess(self,
|
||||
token: typing.Union[str, dict],
|
||||
expected_user_id: typing.Union[str, ObjectId] = None):
|
||||
"""Asserts that this authentication token allows access to /api/users/me."""
|
||||
|
||||
if isinstance(token, dict) and 'token' in token:
|
||||
token = token['token']
|
||||
|
||||
if not isinstance(token, str):
|
||||
raise TypeError(f'token should be a string, but is {token!r}')
|
||||
if expected_user_id and not isinstance(expected_user_id, (str, ObjectId)):
|
||||
raise TypeError('expected_user_id should be a string or ObjectId, '
|
||||
f'but is {expected_user_id!r}')
|
||||
|
||||
resp = self.get('/api/users/me', expected_status=200, auth_token=token).json()
|
||||
|
||||
if expected_user_id:
|
||||
self.assertEqual(resp['_id'], str(expected_user_id))
|
||||
|
||||
|
||||
def mongo_to_sdk(data):
|
||||
"""Transforms a MongoDB dict to a dict suitable to give to the PillarSDK.
|
||||
|
Reference in New Issue
Block a user