diff --git a/avatars/avatars.py b/avatars/avatars.py index eaf58bc52..51f6605ac 100644 --- a/avatars/avatars.py +++ b/avatars/avatars.py @@ -65,6 +65,9 @@ class BaseAvatar(object): def get_data_for_org(self, org): return self.get_data(org.username, org.email, 'org') + def get_data_for_external_user(self, external_user): + return self.get_data(external_user.username, external_user.email, 'user') + def get_data(self, name, email_or_id, kind='user'): """ Computes and returns the full data block for the avatar: { diff --git a/data/model/user.py b/data/model/user.py index b3d5513f9..a94bc4cb8 100644 --- a/data/model/user.py +++ b/data/model/user.py @@ -499,8 +499,7 @@ def get_matching_user_namespaces(namespace_prefix, username, limit=10): return _basequery.filter_to_repos_for_user(base_query, username).limit(limit) -def get_matching_users(username_prefix, robot_namespace=None, - organization=None): +def get_matching_users(username_prefix, robot_namespace=None, organization=None, limit=20): user_search = _basequery.prefix_search(User.username, username_prefix) direct_user_query = (user_search & (User.organization == False) & (User.robot == False)) @@ -516,23 +515,24 @@ def get_matching_users(username_prefix, robot_namespace=None, if organization: query = (query - .select(User.username, User.email, User.robot, fn.Sum(Team.id)) + .select(User.id, User.username, User.email, User.robot, fn.Sum(Team.id)) .join(TeamMember, JOIN_LEFT_OUTER) .join(Team, JOIN_LEFT_OUTER, on=((Team.id == TeamMember.team) & (Team.organization == organization)))) class MatchingUserResult(object): def __init__(self, *args): - self.username = args[0] - self.email = args[1] - self.robot = args[2] + self.id = args[0] + self.username = args[1] + self.email = args[2] + self.robot = args[3] if organization: self.is_org_member = (args[3] != None) else: self.is_org_member = None - return (MatchingUserResult(*args) for args in query.tuples().limit(10)) + return (MatchingUserResult(*args) for args in query.tuples().limit(limit)) def verify_user(username_or_email, password): @@ -749,3 +749,16 @@ def get_region_locations(user): """ Returns the locations defined as preferred storage for the given user. """ query = UserRegion.select().join(ImageStorageLocation).where(UserRegion.user == user) return set([region.location.name for region in query]) + +def get_federated_logins(user_ids, service_name): + """ Returns all federated logins for the given user ids under the given external service. """ + if not user_ids: + return [] + + return (FederatedLogin + .select() + .join(User) + .switch(FederatedLogin) + .join(LoginService) + .where(FederatedLogin.user << user_ids, + LoginService.name == service_name)) diff --git a/data/users/__init__.py b/data/users/__init__.py index 0cebb1cae..6f61b28f8 100644 --- a/data/users/__init__.py +++ b/data/users/__init__.py @@ -139,6 +139,31 @@ class UserAuthentication(object): return data.get('password', encrypted) + @property + def federated_service(self): + """ Returns the name of the federated service for the auth system. If none, should return None. + """ + return self.state.federated_service + + def query_users(self, query, limit=20): + """ Performs a lookup against the user system for the specified query. The returned tuple + will be of the form (results, federated_login_id, err_msg). If the method is unsupported, + the results portion of the tuple will be None instead of empty list. + + Note that this method can and will return results for users not yet found within the + database; it is the responsibility of the caller to call link_user if they need the + database row for the user system record. + + Results will be in the form of objects's with username and email fields. + """ + return self.state.query_users(query, limit) + + def link_user(self, username_or_email): + """ Returns a tuple containing the database user record linked to the given username/email + and any error that occurred when trying to link the user. + """ + return self.state.link_user(username_or_email) + def confirm_existing_user(self, username, password): """ Verifies that the given password matches to the given DB username. Unlike verify_credentials, this call first translates the DB user via the FederatedLogin table diff --git a/data/users/database.py b/data/users/database.py index e8cb3faad..4edb0f442 100644 --- a/data/users/database.py +++ b/data/users/database.py @@ -1,6 +1,10 @@ from data import model class DatabaseUsers(object): + @property + def federated_service(self): + return None + def verify_credentials(self, username_or_email, password): """ Simply delegate to the model implementation. """ result = model.user.verify_user(username_or_email, password) @@ -16,3 +20,11 @@ class DatabaseUsers(object): def confirm_existing_user(self, username, password): return self.verify_credentials(username, password) + def link_user(self, username_or_email): + """ Never used since all users being added are already, by definition, in the database. """ + return (None, 'Unsupported for this authentication system') + + def query_users(self, query, limit): + """ No need to implement, as we already query for users directly in the database. """ + return (None, '', '') + diff --git a/data/users/federated.py b/data/users/federated.py index e70740917..7ba1f4a80 100644 --- a/data/users/federated.py +++ b/data/users/federated.py @@ -7,7 +7,7 @@ from util.validation import generate_valid_usernames logger = logging.getLogger(__name__) -VerifiedCredentials = namedtuple('VerifiedCredentials', ['username', 'email']) +UserInformation = namedtuple('UserInformation', ['username', 'email', 'id']) class FederatedUsers(object): """ Base class for all federated users systems. """ @@ -15,11 +15,26 @@ class FederatedUsers(object): def __init__(self, federated_service): self._federated_service = federated_service + @property + def federated_service(self): + return self._federated_service + + def get_user(self, username_or_email): + """ Retrieves the user with the given username or email, returning a tuple containing + a UserInformation (if success) and the error message (on failure). + """ + raise NotImplementedError + def verify_credentials(self, username_or_email, password): """ Verifies the given credentials against the backing federated service, returning - a tuple containing a VerifiedCredentials (if success) and the error message (if failed). """ + a tuple containing a UserInformation (on success) and the error message (on failure). + """ raise NotImplementedError + def query_users(self, query, limit=20): + """ If implemented, get_user must be implemented as well. """ + return (None, 'Not supported') + def _get_federated_user(self, username, email): db_user = model.user.verify_federated_login(self._federated_service, username) if not db_user: @@ -34,7 +49,8 @@ class FederatedUsers(object): return (None, 'Unable to pick a username. Please report this to your administrator.') db_user = model.user.create_federated_user(valid_username, email, self._federated_service, - username, set_password_notification=False) + username, + set_password_notification=False) else: # Update the db attributes from the federated service. db_user.email = email @@ -42,6 +58,13 @@ class FederatedUsers(object): return (db_user, None) + def link_user(self, username_or_email): + (credentials, err_msg) = self.get_user(username_or_email) + if credentials is None: + return (None, err_msg) + + return self._get_federated_user(credentials.username, credentials.email) + def verify_and_link_user(self, username_or_email, password): """ Verifies the given credentials and, if valid, creates/links a database user to the associated federated service. diff --git a/endpoints/api/search.py b/endpoints/api/search.py index b11f1b53d..b5d233f3b 100644 --- a/endpoints/api/search.py +++ b/endpoints/api/search.py @@ -1,14 +1,15 @@ """ Conduct searches against all registry context. """ from endpoints.api import (ApiResource, parse_args, query_param, truthy_bool, nickname, resource, - require_scope, path_param) + require_scope, path_param, internal_only, Unauthorized, InvalidRequest, + show_if) from data import model from auth.permissions import (OrganizationMemberPermission, ReadRepositoryPermission, UserAdminPermission, AdministerOrganizationPermission, ReadRepositoryPermission) from auth.auth_context import get_authenticated_user from auth import scopes -from app import avatar +from app import avatar, authentication from operator import itemgetter from stringscore import liquidmetal from util.names import parse_robot_username @@ -16,6 +17,32 @@ from util.names import parse_robot_username import anunidecode # Don't listen to pylint's lies. This import is required. import math +@show_if(authentication.federated_service) # Only enabled for non-DB auth. +@resource('/v1/entities/link/') +@internal_only +class LinkExternalEntity(ApiResource): + """ Resource for linking external entities to internal users. """ + @nickname('linkExternalUser') + def post(self, username): + # Only allowed if there is a logged in user. + if not get_authenticated_user(): + raise Unauthorized() + + # Try to link the user with the given *external* username, to an internal record. + (user, err_msg) = authentication.link_user(username) + if user is None: + raise InvalidRequest(err_msg, payload={'username': username}) + + return { + 'entity': { + 'name': user.username, + 'kind': 'user', + 'is_robot': False, + 'avatar': avatar.get_data_for_user(user) + } + } + + @resource('/v1/entities/') class EntitySearch(ApiResource): """ Resource for searching entities. """ @@ -69,7 +96,22 @@ class EntitySearch(ApiResource): if admin_permission.can(): robot_namespace = namespace_name - users = model.user.get_matching_users(prefix, robot_namespace, organization) + # Lookup users in the database for the prefix query. + users = model.user.get_matching_users(prefix, robot_namespace, organization, limit=10) + + # Lookup users via the user system for the prefix query. We'll filter out any users that + # already exist in the database. + external_users, federated_id, _ = authentication.query_users(prefix, limit=10) + filtered_external_users = [] + if external_users and federated_id is not None: + users = list(users) + user_ids = [user.id for user in users] + + # Filter the users if any are already found via the database. We do so by looking up all + # the found users in the federated user system. + federated_query = model.user.get_federated_logins(user_ids, federated_id) + found = {result.service_ident for result in federated_query} + filtered_external_users = [user for user in external_users if not user.username in found] def entity_team_view(team): result = { @@ -93,11 +135,20 @@ class EntitySearch(ApiResource): return user_json + def external_view(user): + result = { + 'name': user.username, + 'kind': 'external', + 'avatar': avatar.get_data_for_external_user(user) + } + return result + team_data = [entity_team_view(team) for team in teams] user_data = [user_view(user) for user in users] + external_data = [external_view(user) for user in filtered_external_users] return { - 'results': team_data + user_data + org_data + 'results': team_data + user_data + org_data + external_data } diff --git a/static/js/directives/ui/entity-search.js b/static/js/directives/ui/entity-search.js index 4611a81ae..c0be63673 100644 --- a/static/js/directives/ui/entity-search.js +++ b/static/js/directives/ui/entity-search.js @@ -148,6 +148,18 @@ angular.module('quay').directive('entitySearch', function () { }; $scope.setEntityInternal = function(entity, updateTypeahead) { + // If the entity is an external entity, convert it to a known user via an API call. + if (entity.kind == 'external') { + var params = { + 'username': entity.name + }; + + ApiService.linkExternalUser(null, params).then(function(resp) { + $scope.setEntityInternal(resp['entity'], updateTypeahead); + }, ApiService.errorDisplay('Could not link external user')); + return; + } + if (updateTypeahead) { $(input).typeahead('val', $scope.autoClear ? '' : entity.name); } else { @@ -193,7 +205,7 @@ angular.module('quay').directive('entitySearch', function () { var entity = data.results[i]; var found = 'user'; - if (entity.kind == 'user') { + if (entity.kind == 'user' || entity.kind == 'external') { found = entity.is_robot ? 'robot' : 'user'; } else if (entity.kind == 'team') { found = 'team'; @@ -276,6 +288,8 @@ angular.module('quay').directive('entitySearch', function () { template = '
'; if (datum.entity.kind == 'user' && !datum.entity.is_robot) { template += ''; + } else if (datum.entity.kind == 'external') { + template += ''; } else if (datum.entity.kind == 'user' && datum.entity.is_robot) { template += ''; } else if (datum.entity.kind == 'team') { diff --git a/test/data/test.db b/test/data/test.db index 433d9e949..522cb90c5 100644 Binary files a/test/data/test.db and b/test/data/test.db differ