Add support for linking to external users in entity search
This commit is contained in:
parent
845d1795a3
commit
d145222812
8 changed files with 156 additions and 15 deletions
|
@ -65,6 +65,9 @@ class BaseAvatar(object):
|
||||||
def get_data_for_org(self, org):
|
def get_data_for_org(self, org):
|
||||||
return self.get_data(org.username, org.email, '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'):
|
def get_data(self, name, email_or_id, kind='user'):
|
||||||
""" Computes and returns the full data block for the avatar:
|
""" Computes and returns the full data block for the avatar:
|
||||||
{
|
{
|
||||||
|
|
|
@ -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)
|
return _basequery.filter_to_repos_for_user(base_query, username).limit(limit)
|
||||||
|
|
||||||
def get_matching_users(username_prefix, robot_namespace=None,
|
def get_matching_users(username_prefix, robot_namespace=None, organization=None, limit=20):
|
||||||
organization=None):
|
|
||||||
user_search = _basequery.prefix_search(User.username, username_prefix)
|
user_search = _basequery.prefix_search(User.username, username_prefix)
|
||||||
direct_user_query = (user_search & (User.organization == False) & (User.robot == False))
|
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:
|
if organization:
|
||||||
query = (query
|
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(TeamMember, JOIN_LEFT_OUTER)
|
||||||
.join(Team, JOIN_LEFT_OUTER, on=((Team.id == TeamMember.team) &
|
.join(Team, JOIN_LEFT_OUTER, on=((Team.id == TeamMember.team) &
|
||||||
(Team.organization == organization))))
|
(Team.organization == organization))))
|
||||||
|
|
||||||
class MatchingUserResult(object):
|
class MatchingUserResult(object):
|
||||||
def __init__(self, *args):
|
def __init__(self, *args):
|
||||||
self.username = args[0]
|
self.id = args[0]
|
||||||
self.email = args[1]
|
self.username = args[1]
|
||||||
self.robot = args[2]
|
self.email = args[2]
|
||||||
|
self.robot = args[3]
|
||||||
|
|
||||||
if organization:
|
if organization:
|
||||||
self.is_org_member = (args[3] != None)
|
self.is_org_member = (args[3] != None)
|
||||||
else:
|
else:
|
||||||
self.is_org_member = None
|
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):
|
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. """
|
""" Returns the locations defined as preferred storage for the given user. """
|
||||||
query = UserRegion.select().join(ImageStorageLocation).where(UserRegion.user == user)
|
query = UserRegion.select().join(ImageStorageLocation).where(UserRegion.user == user)
|
||||||
return set([region.location.name for region in query])
|
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))
|
||||||
|
|
|
@ -139,6 +139,31 @@ class UserAuthentication(object):
|
||||||
|
|
||||||
return data.get('password', encrypted)
|
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):
|
def confirm_existing_user(self, username, password):
|
||||||
""" Verifies that the given password matches to the given DB username. Unlike
|
""" 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
|
verify_credentials, this call first translates the DB user via the FederatedLogin table
|
||||||
|
|
|
@ -1,6 +1,10 @@
|
||||||
from data import model
|
from data import model
|
||||||
|
|
||||||
class DatabaseUsers(object):
|
class DatabaseUsers(object):
|
||||||
|
@property
|
||||||
|
def federated_service(self):
|
||||||
|
return None
|
||||||
|
|
||||||
def verify_credentials(self, username_or_email, password):
|
def verify_credentials(self, username_or_email, password):
|
||||||
""" Simply delegate to the model implementation. """
|
""" Simply delegate to the model implementation. """
|
||||||
result = model.user.verify_user(username_or_email, password)
|
result = model.user.verify_user(username_or_email, password)
|
||||||
|
@ -16,3 +20,11 @@ class DatabaseUsers(object):
|
||||||
def confirm_existing_user(self, username, password):
|
def confirm_existing_user(self, username, password):
|
||||||
return self.verify_credentials(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, '', '')
|
||||||
|
|
||||||
|
|
|
@ -7,7 +7,7 @@ from util.validation import generate_valid_usernames
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
VerifiedCredentials = namedtuple('VerifiedCredentials', ['username', 'email'])
|
UserInformation = namedtuple('UserInformation', ['username', 'email', 'id'])
|
||||||
|
|
||||||
class FederatedUsers(object):
|
class FederatedUsers(object):
|
||||||
""" Base class for all federated users systems. """
|
""" Base class for all federated users systems. """
|
||||||
|
@ -15,11 +15,26 @@ class FederatedUsers(object):
|
||||||
def __init__(self, federated_service):
|
def __init__(self, federated_service):
|
||||||
self._federated_service = 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):
|
def verify_credentials(self, username_or_email, password):
|
||||||
""" Verifies the given credentials against the backing federated service, returning
|
""" 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
|
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):
|
def _get_federated_user(self, username, email):
|
||||||
db_user = model.user.verify_federated_login(self._federated_service, username)
|
db_user = model.user.verify_federated_login(self._federated_service, username)
|
||||||
if not db_user:
|
if not db_user:
|
||||||
|
@ -34,7 +49,8 @@ class FederatedUsers(object):
|
||||||
return (None, 'Unable to pick a username. Please report this to your administrator.')
|
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,
|
db_user = model.user.create_federated_user(valid_username, email, self._federated_service,
|
||||||
username, set_password_notification=False)
|
username,
|
||||||
|
set_password_notification=False)
|
||||||
else:
|
else:
|
||||||
# Update the db attributes from the federated service.
|
# Update the db attributes from the federated service.
|
||||||
db_user.email = email
|
db_user.email = email
|
||||||
|
@ -42,6 +58,13 @@ class FederatedUsers(object):
|
||||||
|
|
||||||
return (db_user, None)
|
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):
|
def verify_and_link_user(self, username_or_email, password):
|
||||||
""" Verifies the given credentials and, if valid, creates/links a database user to the
|
""" Verifies the given credentials and, if valid, creates/links a database user to the
|
||||||
associated federated service.
|
associated federated service.
|
||||||
|
|
|
@ -1,14 +1,15 @@
|
||||||
""" Conduct searches against all registry context. """
|
""" Conduct searches against all registry context. """
|
||||||
|
|
||||||
from endpoints.api import (ApiResource, parse_args, query_param, truthy_bool, nickname, resource,
|
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 data import model
|
||||||
from auth.permissions import (OrganizationMemberPermission, ReadRepositoryPermission,
|
from auth.permissions import (OrganizationMemberPermission, ReadRepositoryPermission,
|
||||||
UserAdminPermission, AdministerOrganizationPermission,
|
UserAdminPermission, AdministerOrganizationPermission,
|
||||||
ReadRepositoryPermission)
|
ReadRepositoryPermission)
|
||||||
from auth.auth_context import get_authenticated_user
|
from auth.auth_context import get_authenticated_user
|
||||||
from auth import scopes
|
from auth import scopes
|
||||||
from app import avatar
|
from app import avatar, authentication
|
||||||
from operator import itemgetter
|
from operator import itemgetter
|
||||||
from stringscore import liquidmetal
|
from stringscore import liquidmetal
|
||||||
from util.names import parse_robot_username
|
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 anunidecode # Don't listen to pylint's lies. This import is required.
|
||||||
import math
|
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>')
|
@resource('/v1/entities/<prefix>')
|
||||||
class EntitySearch(ApiResource):
|
class EntitySearch(ApiResource):
|
||||||
""" Resource for searching entities. """
|
""" Resource for searching entities. """
|
||||||
|
@ -69,7 +96,22 @@ class EntitySearch(ApiResource):
|
||||||
if admin_permission.can():
|
if admin_permission.can():
|
||||||
robot_namespace = namespace_name
|
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):
|
def entity_team_view(team):
|
||||||
result = {
|
result = {
|
||||||
|
@ -93,11 +135,20 @@ class EntitySearch(ApiResource):
|
||||||
|
|
||||||
return user_json
|
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]
|
team_data = [entity_team_view(team) for team in teams]
|
||||||
user_data = [user_view(user) for user in users]
|
user_data = [user_view(user) for user in users]
|
||||||
|
external_data = [external_view(user) for user in filtered_external_users]
|
||||||
|
|
||||||
return {
|
return {
|
||||||
'results': team_data + user_data + org_data
|
'results': team_data + user_data + org_data + external_data
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -148,6 +148,18 @@ angular.module('quay').directive('entitySearch', function () {
|
||||||
};
|
};
|
||||||
|
|
||||||
$scope.setEntityInternal = function(entity, updateTypeahead) {
|
$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) {
|
if (updateTypeahead) {
|
||||||
$(input).typeahead('val', $scope.autoClear ? '' : entity.name);
|
$(input).typeahead('val', $scope.autoClear ? '' : entity.name);
|
||||||
} else {
|
} else {
|
||||||
|
@ -193,7 +205,7 @@ angular.module('quay').directive('entitySearch', function () {
|
||||||
var entity = data.results[i];
|
var entity = data.results[i];
|
||||||
|
|
||||||
var found = 'user';
|
var found = 'user';
|
||||||
if (entity.kind == 'user') {
|
if (entity.kind == 'user' || entity.kind == 'external') {
|
||||||
found = entity.is_robot ? 'robot' : 'user';
|
found = entity.is_robot ? 'robot' : 'user';
|
||||||
} else if (entity.kind == 'team') {
|
} else if (entity.kind == 'team') {
|
||||||
found = 'team';
|
found = 'team';
|
||||||
|
@ -276,6 +288,8 @@ angular.module('quay').directive('entitySearch', function () {
|
||||||
template = '<div class="entity-mini-listing">';
|
template = '<div class="entity-mini-listing">';
|
||||||
if (datum.entity.kind == 'user' && !datum.entity.is_robot) {
|
if (datum.entity.kind == 'user' && !datum.entity.is_robot) {
|
||||||
template += '<i class="fa fa-user fa-lg"></i>';
|
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) {
|
} else if (datum.entity.kind == 'user' && datum.entity.is_robot) {
|
||||||
template += '<i class="fa ci-robot fa-lg"></i>';
|
template += '<i class="fa ci-robot fa-lg"></i>';
|
||||||
} else if (datum.entity.kind == 'team') {
|
} else if (datum.entity.kind == 'team') {
|
||||||
|
|
Binary file not shown.
Reference in a new issue