diff --git a/data/model/permission.py b/data/model/permission.py index f95764c0c..aeeb55c13 100644 --- a/data/model/permission.py +++ b/data/model/permission.py @@ -5,6 +5,17 @@ from data.database import (RepositoryPermission, User, Repository, Visibility, R from data.model import DataModelException, _basequery +def list_team_permissions(team): + return (RepositoryPermission + .select(RepositoryPermission) + .join(Repository) + .join(Visibility) + .switch(RepositoryPermission) + .join(Role) + .switch(RepositoryPermission) + .where(RepositoryPermission.team == team)) + + def list_robot_permissions(robot_name): return (RepositoryPermission .select(RepositoryPermission, User, Repository) diff --git a/data/model/team.py b/data/model/team.py index 1fffa5dbf..a04b95483 100644 --- a/data/model/team.py +++ b/data/model/team.py @@ -191,29 +191,47 @@ def get_teams_within_org(organization): """ Returns a AttrDict of team info (id, name, description), its role under the org, the number of repositories on which it has permission, and the number of members. """ - query = (Team.select(Team.id, Team.name, Team.description, TeamRole.name, - fn.Count(RepositoryPermission.id), fn.Count(TeamMember.id)) + query = (Team.select() .where(Team.organization == organization) - .join(TeamRole) - .switch(Team) - .join(RepositoryPermission, JOIN_LEFT_OUTER) - .switch(Team) - .join(TeamMember, JOIN_LEFT_OUTER) - .group_by(Team.id) - .tuples()) + .join(TeamRole)) - def _team_view(team_tuple): - return AttrDict({ - 'id': team_tuple[0], - 'name': team_tuple[1], - 'description': team_tuple[2], - 'role_name': team_tuple[3], + def _team_view(team): + return { + 'id': team.id, + 'name': team.name, + 'description': team.description, + 'role_name': team.role.name, - 'repo_count': team_tuple[4], - 'member_count': team_tuple[5], - }) + 'repo_count': 0, + 'member_count': 0, + } - return [_team_view(team_tuple) for team_tuple in query] + teams = {team.id: _team_view(team) for team in query} + if not teams: + # Just in case. Should ideally never happen. + return [] + + # Add repository permissions count. + permission_tuples = (RepositoryPermission.select(RepositoryPermission.team, + fn.Count(RepositoryPermission.id)) + .where(RepositoryPermission.team << teams.keys()) + .group_by(RepositoryPermission.team) + .tuples()) + + for perm_tuple in permission_tuples: + teams[perm_tuple[0]]['repo_count'] = perm_tuple[1] + + # Add the member count. + members_tuples = (TeamMember.select(TeamMember.team, + fn.Count(TeamMember.id)) + .where(TeamMember.team << teams.keys()) + .group_by(TeamMember.team) + .tuples()) + + for member_tuple in members_tuples: + teams[member_tuple[0]]['member_count'] = member_tuple[1] + + return [AttrDict(team_info) for team_info in teams.values()] def get_user_teams_within_org(username, organization): diff --git a/endpoints/api/team.py b/endpoints/api/team.py index 051c9bb1a..358887af1 100644 --- a/endpoints/api/team.py +++ b/endpoints/api/team.py @@ -5,7 +5,7 @@ from flask import request import features from endpoints.api import (resource, nickname, ApiResource, validate_json_request, request_error, - log_action, internal_only, require_scope, path_param, query_param, + log_action, internal_only, require_scope, path_param, query_param, truthy_bool, parse_args, require_user_admin, show_if) from endpoints.exception import Unauthorized, NotFound from auth.permissions import AdministerOrganizationPermission, ViewTeamPermission @@ -15,6 +15,15 @@ from data import model from util.useremails import send_org_invite_email from app import avatar +def permission_view(permission): + return { + 'repository': { + 'name': permission.repository.name, + 'is_public': permission.repository.visibility.name == 'public' + }, + 'role': permission.role.name + } + def try_accept_invite(code, user): (team, inviter) = model.team.confirm_team_invite(code, user) @@ -346,6 +355,30 @@ class InviteTeamMember(ApiResource): raise Unauthorized() +@resource('/v1/organization//team//permissions') +@path_param('orgname', 'The name of the organization') +@path_param('teamname', 'The name of the team') +class TeamPermissions(ApiResource): + """ Resource for listing the permissions an org's team has in the system. """ + @nickname('getTeamPermissions') + def get(self, orgname, teamname): + """ Returns the list of repository permissions for the org's team. """ + permission = AdministerOrganizationPermission(orgname) + if permission.can(): + try: + team = model.team.get_organization_team(orgname, teamname) + except model.InvalidTeamException: + raise NotFound() + + permissions = model.permission.list_team_permissions(team) + + return { + 'permissions': [permission_view(permission) for permission in permissions] + } + + raise Unauthorized() + + @resource('/v1/teaminvite/') @internal_only @show_if(features.MAILING) diff --git a/static/css/directives/ui/add-repo-permissions.css b/static/css/directives/ui/add-repo-permissions.css deleted file mode 100644 index dbab124da..000000000 --- a/static/css/directives/ui/add-repo-permissions.css +++ /dev/null @@ -1,35 +0,0 @@ -.add-repo-permissions-element label { - margin-top: 4px; -} - -.add-repo-permissions-element .co-table { - margin-top: 20px; -} - -.add-repo-permissions-element .fa-hdd-o { - margin-right: 4px; - vertical-align: middle; -} - -.add-repo-permissions-element .co-filter-box { - display: block; - float: right; - margin-bottom: 20px; -} - -.add-repo-permissions-element .co-filter-box .filter-message { - left: -180px; - top: 4px; -} - -.add-repo-permissions-element .co-filter-box input { - width: 100%; - padding-top: 2px; - padding-bottom: 2px; - height: 28px; -} - -.add-repo-permissions-element label .avatar { - vertical-align: text-bottom; - margin-left: 4px; -} \ No newline at end of file diff --git a/static/css/directives/ui/set-repo-permissions.css b/static/css/directives/ui/set-repo-permissions.css new file mode 100644 index 000000000..f5d8b0087 --- /dev/null +++ b/static/css/directives/ui/set-repo-permissions.css @@ -0,0 +1,35 @@ +.set-repo-permissions-element label { + margin-top: 4px; +} + +.set-repo-permissions-element .co-table { + margin-top: 20px; +} + +.set-repo-permissions-element .fa-hdd-o { + margin-right: 4px; + vertical-align: middle; +} + +.set-repo-permissions-element .co-filter-box { + display: block; + float: right; + margin-bottom: 20px; +} + +.set-repo-permissions-element .co-filter-box .filter-message { + left: -180px; + top: 4px; +} + +.set-repo-permissions-element .co-filter-box input { + width: 100%; + padding-top: 2px; + padding-bottom: 2px; + height: 28px; +} + +.set-repo-permissions-element label .avatar { + vertical-align: text-bottom; + margin-left: 4px; +} \ No newline at end of file diff --git a/static/directives/create-entity-dialog.html b/static/directives/create-entity-dialog.html index 6447e99c3..a3ef2d966 100644 --- a/static/directives/create-entity-dialog.html +++ b/static/directives/create-entity-dialog.html @@ -8,23 +8,24 @@ Create {{ entityTitle }} - - + +
+
diff --git a/static/directives/set-repo-permissions-dialog.html b/static/directives/set-repo-permissions-dialog.html new file mode 100644 index 000000000..1f40780ad --- /dev/null +++ b/static/directives/set-repo-permissions-dialog.html @@ -0,0 +1,35 @@ +
+ +
\ No newline at end of file diff --git a/static/directives/add-repo-permissions.html b/static/directives/set-repo-permissions.html similarity index 98% rename from static/directives/add-repo-permissions.html rename to static/directives/set-repo-permissions.html index 972763066..296fbef4a 100644 --- a/static/directives/add-repo-permissions.html +++ b/static/directives/set-repo-permissions.html @@ -1,4 +1,4 @@ -
+
Showing {{ orderedRepositories.entries.length }} of {{ repositories.length }} repositories diff --git a/static/directives/teams-manager.html b/static/directives/teams-manager.html index 722a33564..714b5711a 100644 --- a/static/directives/teams-manager.html +++ b/static/directives/teams-manager.html @@ -81,19 +81,31 @@ - repositories + + + No repositories + + + + + + {{ team.repo_count }} + repository + repositories + + - + Manage Team Members - - Add Repository Permissions + + Set Repository Permissions Delete Team {{ team.name }} @@ -169,8 +181,13 @@
+
+ +
+
= repos.length) { - $scope.permissionsAdded({'repositories': repos}); + $scope.permissionsSet({'repositories': $scope.checkedRepos.checked}); + $scope.checkedRepos.setChecked([]); return; } var repo = repos[counter]; + if (repo['permission'] == repo['original_permission']) { + // Skip changing it. + counter++; + setPerm(); + return; + } + RolesService.setRepositoryRole(repo, repo.permission, $scope.entityKind, - $scope.entityName, - function(status) { - if (status) { - counter++; - addPerm(); - } else { - $scope.permissionsAdded(); - } - }); + $scope.entityName, function(status) { + if (status) { + counter++; + setPerm(); + } + }); }; - addPerm(); + setPerm(); }; $scope.setRole = function(role, repo) { @@ -157,6 +200,8 @@ angular.module('quay').directive('addRepoPermissions', function () { } else { $scope.checkedRepos.checkItem(repo); } + + checkForChanges(); }; $scope.allRepositoriesFilter = function(item) { @@ -175,13 +220,13 @@ angular.module('quay').directive('addRepoPermissions', function () { $scope.$watch('options.reverse', setRepoState); $scope.$watch('options.filter', setRepoState); - $scope.$watch('namespace', loadRepositories); - $scope.$watch('entityName', loadRepositories); - $scope.$watch('entityKind', loadRepositories); + $scope.$watch('namespace', loadRepositoriesAndPermissions); + $scope.$watch('entityName', loadRepositoriesAndPermissions); + $scope.$watch('entityKind', loadRepositoriesAndPermissions); - $scope.$watch('addPermissions', function(value) { + $scope.$watch('setPermissions', function(value) { if (value) { - addPermissions(); + setPermissions(); } }); } diff --git a/static/js/directives/ui/teams-manager.js b/static/js/directives/ui/teams-manager.js index 69c752c3f..7235c91f2 100644 --- a/static/js/directives/ui/teams-manager.js +++ b/static/js/directives/ui/teams-manager.js @@ -196,6 +196,20 @@ angular.module('quay').directive('teamsManager', function () { $scope.removeMemberInfo = $.extend({}, memberInfo); }; + $scope.setRepoPermissions = function(teamName) { + $scope.setRepoPermissionsInfo = { + 'namespace': $scope.organization.name, + 'entityName': teamName, + 'entityKind': 'team', + 'entityIcon': 'fa-group' + }; + }; + + $scope.handlePermissionsSet = function(info, repositories) { + var team = $scope.organization.teams[info.entityName]; + team['repo_count'] = repositories.length; + }; + $scope.$watch('organization', setTeamsState); $scope.$watch('isEnabled', setTeamsState); diff --git a/static/js/services/roles-service.js b/static/js/services/roles-service.js index 27a258598..89def37ab 100644 --- a/static/js/services/roles-service.js +++ b/static/js/services/roles-service.js @@ -1,7 +1,8 @@ /** * Service which defines the various role groups. */ -angular.module('quay').factory('RolesService', ['UtilService', 'Restangular', 'ApiService', function(UtilService, Restangular, ApiService) { +angular.module('quay').factory('RolesService', ['UtilService', 'Restangular', 'ApiService', 'UserService', + function(UtilService, Restangular, ApiService, UserService) { var roleService = {}; roleService.repoRolesOrNone = [ @@ -20,14 +21,22 @@ angular.module('quay').factory('RolesService', ['UtilService', 'Restangular', 'A { 'id': 'admin', 'title': 'Admin', 'kind': 'primary', 'description': 'Full admin access to the organization' } ]; - var getPermissionEndpoint = function(repository, entityName, kind) { + var getPermissionEndpoint = function(repository, entityName, entityKind) { + if (entityKind == 'robot') { + entityKind = 'user'; + } + var namespace = repository.namespace; var name = repository.name; - var url = UtilService.getRestUrl('repository', namespace, name, 'permissions', kind, entityName); + var url = UtilService.getRestUrl('repository', namespace, name, 'permissions', entityKind, entityName); return Restangular.one(url); }; roleService.deleteRepositoryRole = function(repository, entityKind, entityName, callback) { + if (entityKind == 'robot') { + entityKind = 'user'; + } + var errorDisplay = ApiService.errorDisplay('Cannot change permission', function(resp) { callback(false); }); @@ -39,6 +48,15 @@ angular.module('quay').factory('RolesService', ['UtilService', 'Restangular', 'A }; roleService.setRepositoryRole = function(repository, role, entityKind, entityName, callback) { + if (role == 'none') { + roleService.deleteRepositoryRole(repository, entityKind, entityName, callback); + return; + } + + if (entityKind == 'robot') { + entityKind = 'user'; + } + var errorDisplay = ApiService.errorDisplay('Cannot change permission', function(resp) { callback(false); }); @@ -53,5 +71,30 @@ angular.module('quay').factory('RolesService', ['UtilService', 'Restangular', 'A }, errorDisplay); }; + roleService.getRepoPermissions = function(namespace, entityKind, entityName, callback) { + var errorHandler = ApiService.errorDisplay('Could not load permissions', callback); + + if (entityKind == 'team') { + var params = { + 'orgname': namespace, + 'teamname': entityName + }; + + ApiService.getTeamPermissions(null, params).then(function(resp) { + callback(resp.permissions); + }, errorHandler); + } else if (entityKind == 'robot') { + var parts = entityName.split('+'); + var shortName = parts[1]; + + var orgname = UserService.isOrganization(namespace) ? namespace : null; + ApiService.getRobotPermissions(orgname, null, {'robot_shortname': shortName}).then(function(resp) { + callback(resp.permissions); + }, errorHandler); + } else { + throw Error('Unknown entity kind ' + entityKind); + } + }; + return roleService; }]); diff --git a/test/test_api_security.py b/test/test_api_security.py index 258efde7d..17e46f74d 100644 --- a/test/test_api_security.py +++ b/test/test_api_security.py @@ -10,7 +10,8 @@ from data import model from initdb import setup_database_for_testing, finished_database_for_testing from endpoints.api import api_bp, api -from endpoints.api.team import TeamMember, TeamMemberList, OrganizationTeam, TeamMemberInvite +from endpoints.api.team import (TeamMember, TeamMemberList, OrganizationTeam, TeamMemberInvite, + TeamPermissions) from endpoints.api.tag import RepositoryTagImages, RepositoryTag, ListRepositoryTags, RevertTag from endpoints.api.search import EntitySearch from endpoints.api.image import RepositoryImage, RepositoryImageList @@ -678,6 +679,24 @@ class TestTeamMemberBuynlargeDevtableOwners(ApiTestCase): self._run_test('DELETE', 400, 'devtable', None) +class TestTeamPermissionsBuynlarge(ApiTestCase): + def setUp(self): + ApiTestCase.setUp(self) + self._set_url(TeamPermissions, orgname="buynlarge", teamname="readers") + + def test_get_anonymous(self): + self._run_test('GET', 401, None, None) + + def test_get_freshuser(self): + self._run_test('GET', 403, 'freshuser', None) + + def test_get_reader(self): + self._run_test('GET', 403, 'reader', None) + + def test_get_devtable(self): + self._run_test('GET', 200, 'devtable', None) + + class TestTeamMemberListBuynlargeReaders(ApiTestCase): def setUp(self): ApiTestCase.setUp(self) diff --git a/test/test_api_usage.py b/test/test_api_usage.py index f7a8dcb16..69fed58bb 100644 --- a/test/test_api_usage.py +++ b/test/test_api_usage.py @@ -27,7 +27,8 @@ from data import database, model from data.database import RepositoryActionCount, Repository as RepositoryTable from test.helpers import assert_action_logged -from endpoints.api.team import TeamMember, TeamMemberList, TeamMemberInvite, OrganizationTeam +from endpoints.api.team import (TeamMember, TeamMemberList, TeamMemberInvite, OrganizationTeam, + TeamPermissions) from endpoints.api.tag import RepositoryTagImages, RepositoryTag, RevertTag, ListRepositoryTags from endpoints.api.search import EntitySearch, ConductSearch from endpoints.api.image import RepositoryImage, RepositoryImageList @@ -1155,6 +1156,16 @@ class TestDeleteOrganizationTeam(ApiTestCase): self.assertEquals(msg, data['message']) +class TestTeamPermissions(ApiTestCase): + def test_team_permissions(self): + self.login(ADMIN_ACCESS_USER) + + resp = self.getJsonResponse(TeamPermissions, + params=dict(orgname=ORGANIZATION, teamname='readers')) + + self.assertEquals(1, len(resp['permissions'])) + + class TestGetOrganizationTeamMembers(ApiTestCase): def test_invalidteam(self): self.login(ADMIN_ACCESS_USER)