From 100ec563faf3e1ff38189b451e2a694513507ccf Mon Sep 17 00:00:00 2001 From: Joseph Schorr Date: Fri, 1 Nov 2013 21:48:10 -0400 Subject: [PATCH] - Add an entity-search directive for adding a nice search box for users or teams - Add support for team-based permissions to the repos --- data/model.py | 16 ++- endpoints/api.py | 159 ++++++++++++++++++++++++--- static/css/quay.css | 29 ++++- static/directives/entity-search.html | 1 + static/js/app.js | 76 +++++++++++++ static/js/controllers.js | 108 +++++++++--------- static/partials/repo-admin.html | 66 +++++++++-- 7 files changed, 362 insertions(+), 93 deletions(-) create mode 100644 static/directives/entity-search.html diff --git a/data/model.py b/data/model.py index d65a028b2..e1ea835bb 100644 --- a/data/model.py +++ b/data/model.py @@ -207,8 +207,13 @@ def get_user(username): return None +def get_matching_teams(team_prefix, organization): + query = Team.select().where(Team.name ** (team_prefix + '%'), Team.organization == organization) + return list(query.limit(10)) + + def get_matching_users(username_prefix): - query = User.select().where(User.username ** (username_prefix + '%')) + query = User.select().where(User.username ** (username_prefix + '%'), User.organization == False) return list(query.limit(10)) @@ -328,6 +333,15 @@ def get_all_user_permissions(user): return with_role.where(User.username == user.username) +def get_all_repo_teams(namespace_name, repository_name): + select = RepositoryPermission.select(Team.name.alias('team_name'), Role.name, + RepositoryPermission) + with_team = select.join(Team) + with_role = with_team.switch(RepositoryPermission).join(Role) + with_repo = with_role.switch(RepositoryPermission).join(Repository) + return with_repo.where(Repository.namespace == namespace_name, + Repository.name == repository_name) + def get_all_repo_users(namespace_name, repository_name): select = RepositoryPermission.select(User.username, Role.name, RepositoryPermission) diff --git a/endpoints/api.py b/endpoints/api.py index 290a392ff..ec8e31ab5 100644 --- a/endpoints/api.py +++ b/endpoints/api.py @@ -188,6 +188,46 @@ def get_matching_users(prefix): }) +@app.route('/api/entities/', methods=['GET']) +@api_login_required +def get_matching_entities(prefix): + users = model.get_matching_users(prefix) + teams = [] + + organization_name = request.args.get('organization', None) + organization = None + if organization_name: + try: + organization = model.get_organization(organization_name) + except: + pass + + if organization: + # TODO: ensure that the user has access to the organization + teams = model.get_matching_teams(prefix, organization) + + def team_view(team): + return { + 'name': team.name, + 'kind': 'team' + } + + def user_view(user): + # TODO: Return whether the user is outside the organization (if one is + # specified) + return { + 'name': user.username, + 'kind': 'user', + 'outside_org': True + } + + team_data = [team_view(team) for team in teams] + user_data = [user_view(user) for user in users] + return jsonify({ + 'results': team_data + user_data + }) + + user_files = UserRequestFiles(app.config['AWS_ACCESS_KEY'], app.config['AWS_SECRET_KEY'], app.config['REGISTRY_S3_BUCKET']) @@ -225,8 +265,10 @@ def get_organization_private_allowed(orgname): abort(404) user = current_user.db_user() - organization = model.lookup_organization(orgname, username = user.username) - if not organization: + + try: + organization = model.get_organization(orgname, username = user.username) + except: abort(404) private_repos = model.get_private_repo_count(organization.username) @@ -405,6 +447,12 @@ def get_repo_api(namespace, repository): 'image': image_view(image), } + organization = None + try: + organization = model.get_organization(namespace) + except: + pass + permission = ReadRepositoryPermission(namespace, repository) is_public = model.repository_is_public(namespace, repository) if permission.can() or is_public: @@ -426,6 +474,7 @@ def get_repo_api(namespace, repository): 'can_admin': can_admin, 'is_public': is_public, 'is_building': len(active_builds) > 0, + 'is_organization': bool(organization) }) abort(404) # Not fount @@ -501,11 +550,11 @@ def request_repo_build(namespace, repository): abort(403) # Permissions denied -def user_role_view(repo_perm_obj, username): - # TODO: Determine whether the user is outside of the organization. +def role_view(repo_perm_obj, username=None): + # TODO: Determine whether the user (if given) is outside of the organization. return { 'role': repo_perm_obj.role.name, - 'outside_org': False + 'outside_org': username != 'devtable' } @@ -586,42 +635,73 @@ def list_tag_images(namespace, repository, tag): abort(403) # Permission denied -@app.route('/api/repository//permissions/', methods=['GET']) +@app.route('/api/repository//permissions/team/', methods=['GET']) @api_login_required @parse_repository_name -def list_repo_permissions(namespace, repository): +def list_repo_team_permissions(namespace, repository): permission = AdministerRepositoryPermission(namespace, repository) if permission.can(): - repo_perms = model.get_all_repo_users(namespace, repository) + repo_perms = model.get_all_repo_teams(namespace, repository) return jsonify({ - 'permissions': {repo_perm.user.username: user_role_view(repo_perm, repo_perm.user.username) + 'permissions': {repo_perm.team.name: role_view(repo_perm) for repo_perm in repo_perms} }) abort(403) # Permission denied -@app.route('/api/repository//permissions/', +@app.route('/api/repository//permissions/user/', methods=['GET']) +@api_login_required +@parse_repository_name +def list_repo_user_permissions(namespace, repository): + permission = AdministerRepositoryPermission(namespace, repository) + if permission.can(): + repo_perms = model.get_all_repo_users(namespace, repository) + + return jsonify({ + 'permissions': {repo_perm.user.username: role_view(repo_perm, username=repo_perm.user.username) + for repo_perm in repo_perms} + }) + + abort(403) # Permission denied + + +@app.route('/api/repository//permissions/user/', methods=['GET']) @api_login_required @parse_repository_name -def get_permissions(namespace, repository, username): +def get_user_permissions(namespace, repository, username): logger.debug('Get repo: %s/%s permissions for user %s' % (namespace, repository, username)) permission = AdministerRepositoryPermission(namespace, repository) if permission.can(): perm = model.get_user_reponame_permission(username, namespace, repository) - return jsonify(user_role_view(perm, username)) + return jsonify(role_view(perm, username=username)) abort(403) # Permission denied -@app.route('/api/repository//permissions/', +@app.route('/api/repository//permissions/team/', + methods=['GET']) +@api_login_required +@parse_repository_name +def get_team_permissions(namespace, repository, teamname): + logger.debug('Get repo: %s/%s permissions for team %s' % + (namespace, repository, teamname)) + permission = AdministerRepositoryPermission(namespace, repository) + if permission.can(): + perm = model.get_team_reponame_permission(username, namespace, repository) + return jsonify(role_view(perm)) + + abort(403) # Permission denied + + +@app.route('/api/repository//permissions/user/', methods=['PUT', 'POST']) @api_login_required @parse_repository_name -def change_permissions(namespace, repository, username): +def change_user_permissions(namespace, repository, username): permission = AdministerRepositoryPermission(namespace, repository) if permission.can(): new_permission = request.get_json() @@ -636,7 +716,7 @@ def change_permissions(namespace, repository, username): logger.warning('User tried to remove themselves as admin.') abort(409) - resp = jsonify(user_role_view(perm, username)) + resp = jsonify(role_view(perm, username=username)) if request.method == 'POST': resp.status_code = 201 return resp @@ -644,11 +724,38 @@ def change_permissions(namespace, repository, username): abort(403) # Permission denied -@app.route('/api/repository//permissions/', +@app.route('/api/repository//permissions/team/', + methods=['PUT', 'POST']) +@api_login_required +@parse_repository_name +def change_team_permissions(namespace, repository, teamname): + permission = AdministerRepositoryPermission(namespace, repository) + if permission.can(): + new_permission = request.get_json() + + logger.debug('Setting permission to: %s for team %s' % + (new_permission['role'], teamname)) + + try: + perm = model.set_team_repo_permission(teamname, namespace, repository, + new_permission['role']) + except model.DataModelException: + logger.warning('User tried to remove themselves as admin.') + abort(409) + + resp = jsonify(role_view(perm)) + if request.method == 'POST': + resp.status_code = 201 + return resp + + abort(403) # Permission denied + + +@app.route('/api/repository//permissions/user/', methods=['DELETE']) @api_login_required @parse_repository_name -def delete_permissions(namespace, repository, username): +def delete_user_permissions(namespace, repository, username): permission = AdministerRepositoryPermission(namespace, repository) if permission.can(): try: @@ -662,6 +769,24 @@ def delete_permissions(namespace, repository, username): abort(403) # Permission denied +@app.route('/api/repository//permissions/team/', + methods=['DELETE']) +@api_login_required +@parse_repository_name +def delete_team_permissions(namespace, repository, teamname): + permission = AdministerRepositoryPermission(namespace, repository) + if permission.can(): + try: + model.delete_team_permission(teamname, namespace, repository) + except model.DataModelException: + logger.warning('User tried to remove themselves as admin.') + abort(409) + + return make_response('Deleted', 204) + + abort(403) # Permission denied + + def token_view(token_obj): return { 'friendlyName': token_obj.friendly_name, diff --git a/static/css/quay.css b/static/css/quay.css index 54f6a431c..fa199192c 100644 --- a/static/css/quay.css +++ b/static/css/quay.css @@ -574,14 +574,20 @@ form input.ng-valid.ng-dirty { } -.user-mini-listing { +.entity-mini-listing { margin: 2px; } -.user-mini-listing i { +.entity-mini-listing i { margin-right: 8px; } +.entity-mini-listing .warning { + margin-top: 6px; + font-size: 10px; + padding: 4px; +} + .editable { position: relative; } @@ -898,6 +904,10 @@ p.editable:hover i { padding-left: 44px; } +.repo-admin .entity-search input { + width: 300px; +} + .repo-admin .token-dialog-body .well { margin-bottom: 0px; } @@ -916,10 +926,21 @@ p.editable:hover i { } .repo-admin .user i { - margin-right: 6px; + margin-left: 2px; + margin-right: 7px; + color: rgb(79, 195, 79); } -.repo-admin .user { +.repo-admin .user.outside i { + color: rgb(224, 173, 41); +} + +.repo-admin .team i { + margin-right: 4px; + color: rgb(79, 195, 79); +} + +.repo-admin .entity { font-size: 1.2em; min-width: 300px; } diff --git a/static/directives/entity-search.html b/static/directives/entity-search.html new file mode 100644 index 000000000..37e2d1a3f --- /dev/null +++ b/static/directives/entity-search.html @@ -0,0 +1 @@ + diff --git a/static/js/app.js b/static/js/app.js index f72ac7b24..dd5fd151c 100644 --- a/static/js/app.js +++ b/static/js/app.js @@ -233,6 +233,82 @@ quayApp.directive('repoCircle', function () { }); +quayApp.directive('entitySearch', function () { + var number = 0; + var directiveDefinitionObject = { + priority: 0, + templateUrl: '/static/directives/entity-search.html', + replace: false, + transclude: false, + restrict: 'C', + scope: { + 'organization': '=organization', + 'inputTitle': '=inputTitle', + 'entitySelected': '=entitySelected' + }, + controller: function($scope, $element) { + if (!$scope.entitySelected) { return; } + + number++; + + var input = $element[0].firstChild; + $scope.organization = $scope.organization || ''; + $(input).typeahead({ + name: 'entities' + number, + remote: { + url: '/api/entities/%QUERY', + replace: function (url, uriEncodedQuery) { + url = url.replace('%QUERY', uriEncodedQuery); + if ($scope.organization) { + url += '?organization=' + encodeURIComponent($scope.organization); + } + return url; + }, + filter: function(data) { + var datums = []; + for (var i = 0; i < data.results.length; ++i) { + var entity = data.results[i]; + datums.push({ + 'value': entity.name, + 'tokens': [entity.name], + 'entity': entity + }); + } + return datums; + } + }, + template: function (datum) { + template = '
'; + if (datum.entity.kind == 'user') { + template += ''; + } else if (datum.entity.kind == 'team') { + template += ''; + } + template += '' + datum.value + ''; + + if (datum.entity.outside_org) { + template += '
This user is outside your organization
'; + } + + template += '
'; + return template; + }, + }); + + $(input).on('typeahead:selected', function(e, datum) { + $(input).typeahead('setQuery', ''); + $scope.entitySelected(datum.entity); + }); + + $scope.$watch('inputTitle', function(title) { + input.setAttribute('placeholder', title); + }); + } + }; + return directiveDefinitionObject; +}); + + quayApp.directive('namespaceSelector', function () { var directiveDefinitionObject = { priority: 0, diff --git a/static/js/controllers.js b/static/js/controllers.js index 7d0dbb6ff..3ab8578dd 100644 --- a/static/js/controllers.js +++ b/static/js/controllers.js @@ -527,39 +527,7 @@ function RepoAdminCtrl($scope, Restangular, $routeParams, $rootScope) { var namespace = $routeParams.namespace; var name = $routeParams.name; - $scope.$on('$viewContentLoaded', function() { - // THIS IS BAD, MOVE THIS TO A DIRECTIVE - $('#userSearch').typeahead({ - name: 'users', - remote: { - url: '/api/users/%QUERY', - filter: function(data) { - var datums = []; - for (var i = 0; i < data.users.length; ++i) { - var user = data.users[i]; - datums.push({ - 'value': user, - 'tokens': [user], - 'username': user - }); - } - return datums; - } - }, - template: function (datum) { - template = '
'; - template += '' - template += '' + datum.username + '' - template += '
' - return template; - }, - }); - - $('#userSearch').on('typeahead:selected', function(e, datum) { - $('#userSearch').typeahead('setQuery', ''); - $scope.addNewPermission(datum.username); - }); - }); + $scope.permissions = {'team': [], 'user': []}; $scope.isDownloadSupported = function() { try { return !!new Blob(); } catch(e){} @@ -580,21 +548,34 @@ function RepoAdminCtrl($scope, Restangular, $routeParams, $rootScope) { saveAs(blob, '.dockercfg'); }; - $scope.addNewPermission = function(username) { + $scope.grantRole = function() { + $('#confirmaddoutsideModal').modal('hide'); + var entity = $scope.currentAddEntity; + $scope.addRole(entity.name, 'read', entity.kind, entity.outside_org) + $scope.currentAddEntity = null; + }; + + $scope.addNewPermission = function(entity) { // Don't allow duplicates. - if ($scope.permissions[username]) { return; } + if ($scope.permissions[entity.kind][entity.name]) { return; } + + if (entity.outside_org) { + $scope.currentAddEntity = entity; + $('#confirmaddoutsideModal').modal('show'); + return; + } // Need the $scope.apply for both the permission stuff to change and for // the XHR call to be made. $scope.$apply(function() { - $scope.addRole(username, 'read') + $scope.addRole(entity.name, 'read', entity.kind, entity.outside_org) }); }; - $scope.deleteRole = function(username) { - var permissionDelete = Restangular.one('repository/' + namespace + '/' + name + '/permissions/' + username); + $scope.deleteRole = function(entityName, kind) { + var permissionDelete = Restangular.one('repository/' + namespace + '/' + name + '/permissions/' + kind + '/' + entityName); permissionDelete.customDELETE().then(function() { - delete $scope.permissions[username]; + delete $scope.permissions[kind][entityName]; }, function(result) { if (result.status == 409) { $('#onlyadminModal').modal({}); @@ -604,26 +585,26 @@ function RepoAdminCtrl($scope, Restangular, $routeParams, $rootScope) { }); }; - $scope.addRole = function(username, role) { + $scope.addRole = function(entityName, role, kind, outside_org) { var permission = { - 'role': role + 'role': role, + 'outside_org': !!outside_org }; - var permissionPost = Restangular.one('repository/' + namespace + '/' + name + '/permissions/' + username); + var permissionPost = Restangular.one('repository/' + namespace + '/' + name + '/permissions/' + kind + '/' + entityName); permissionPost.customPOST(permission).then(function() { - $scope.permissions[username] = permission; - $scope.permissions = $scope.permissions; + $scope.permissions[kind][entityName] = permission; }, function(result) { $('#cannotchangeModal').modal({}); }); }; - $scope.setRole = function(username, role) { - var permission = $scope.permissions[username]; + $scope.setRole = function(entityName, role, kind) { + var permission = $scope.permissions[kind][entityName]; var currentRole = permission.role; permission.role = role; - var permissionPut = Restangular.one('repository/' + namespace + '/' + name + '/permissions/' + username); + var permissionPut = Restangular.one('repository/' + namespace + '/' + name + '/permissions/' + kind + '/' + entityName); permissionPut.customPUT(permission).then(function() {}, function(result) { if (result.status == 409) { permission.role = currentRole; @@ -709,6 +690,23 @@ function RepoAdminCtrl($scope, Restangular, $routeParams, $rootScope) { $scope.loading = true; + var checkLoading = function() { + $scope.loading = !($scope.permissions['user'] && $scope.permissions['team'] && $scope.repo && $scope.tokens); + }; + + var fetchPermissions = function(kind) { + var permissionsFetch = Restangular.one('repository/' + namespace + '/' + name + '/permissions/' + kind + '/'); + permissionsFetch.get().then(function(resp) { + $rootScope.title = 'Settings - ' + namespace + '/' + name; + $scope.permissions[kind] = resp.permissions; + checkLoading(); + }, function() { + $scope.permissions[kind] = null; + $rootScope.title = 'Unknown Repository'; + $scope.loading = false; + }); + }; + // Fetch the repository information. var repositoryFetch = Restangular.one('repository/' + namespace + '/' + name); repositoryFetch.get().then(function(repo) { @@ -720,23 +718,15 @@ function RepoAdminCtrl($scope, Restangular, $routeParams, $rootScope) { $scope.loading = false; }); - // Fetch the permissions. - var permissionsFetch = Restangular.one('repository/' + namespace + '/' + name + '/permissions'); - permissionsFetch.get().then(function(resp) { - $rootScope.title = 'Settings - ' + namespace + '/' + name; - $scope.permissions = resp.permissions; - $scope.loading = !($scope.permissions && $scope.repo && $scope.tokens); - }, function() { - $scope.permissions = null; - $rootScope.title = 'Unknown Repository'; - $scope.loading = false; - }); + // Fetch the user and team permissions. + fetchPermissions('user'); + fetchPermissions('team'); // Fetch the tokens. var tokensFetch = Restangular.one('repository/' + namespace + '/' + name + '/tokens/'); tokensFetch.get().then(function(resp) { $scope.tokens = resp.tokens; - $scope.loading = !($scope.permissions && $scope.repo && $scope.tokens); + checkLoading(); }, function() { $scope.tokens = null; $scope.loading = false; diff --git a/static/partials/repo-admin.html b/static/partials/repo-admin.html index e3d341cd3..cd6e4c4b1 100644 --- a/static/partials/repo-admin.html +++ b/static/partials/repo-admin.html @@ -20,36 +20,58 @@
-
User Access Permissions +
User and Team Access Permissions - +
- + - - + + + + + + + + @@ -57,7 +79,7 @@
UserUser/Team Permissions
- - {{username}} + +
+ + {{name}}
- - - + + +
- + + + +
+ + {{name}} + +
+ + + +
+
+ +
- +
@@ -283,4 +305,24 @@
+ + + +