Add support for linking to external users in entity search

This commit is contained in:
Joseph Schorr 2016-10-27 15:31:32 -04:00
parent 845d1795a3
commit d145222812
8 changed files with 156 additions and 15 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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/<username>')
@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/<prefix>')
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
}

View file

@ -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 = '<div class="entity-mini-listing">';
if (datum.entity.kind == 'user' && !datum.entity.is_robot) {
template += '<i class="fa fa-user fa-lg"></i>';
} else if (datum.entity.kind == 'external') {
template += '<i class="fa fa-user fa-lg"></i>';
} else if (datum.entity.kind == 'user' && datum.entity.is_robot) {
template += '<i class="fa ci-robot fa-lg"></i>';
} else if (datum.entity.kind == 'team') {

Binary file not shown.