From 1f5e6df678d3cc3c06d6977d813d01f1c9132645 Mon Sep 17 00:00:00 2001 From: Joseph Schorr Date: Tue, 31 Mar 2015 18:50:43 -0400 Subject: [PATCH] - Fix tests - Add new endpoints for retrieving the repo permissions for a robot account - Have the robots list return the number of repositories for which there are permissions - Other UI fixes --- data/model/legacy.py | 16 ++- endpoints/api/prototype.py | 3 + endpoints/api/robot.py | 59 ++++++++- endpoints/api/user.py | 4 +- static/css/core-ui.css | 11 ++ static/css/directives/ui/robots-manager.css | 86 ++++++++++++++ static/css/pages/org-view.css | 16 +++ static/css/quay.css | 21 ---- static/directives/robots-manager.html | 112 ++++++++++++++---- static/directives/role-group.html | 3 +- .../ui/repository-permissions-table.js | 1 + static/js/directives/ui/robots-manager.js | 25 ++++ static/js/directives/ui/role-group.js | 1 + static/partials/org-view.html | 11 +- test/test_api_security.py | 43 ++++++- test/test_api_usage.py | 4 +- 16 files changed, 356 insertions(+), 60 deletions(-) create mode 100644 static/css/directives/ui/robots-manager.css diff --git a/data/model/legacy.py b/data/model/legacy.py index 8d5a7d656..e140a1fbc 100644 --- a/data/model/legacy.py +++ b/data/model/legacy.py @@ -312,9 +312,23 @@ def _list_entity_robots(entity_name): def list_entity_robot_tuples(entity_name): return (_list_entity_robots(entity_name) - .select(User.username, FederatedLogin.service_ident) + .join(RepositoryPermission, JOIN_LEFT_OUTER, + on=(RepositoryPermission.user == FederatedLogin.user)) + .join(Repository, JOIN_LEFT_OUTER) + .switch(User) + .group_by(User, FederatedLogin) + .select(User.username, FederatedLogin.service_ident, fn.Count(Repository.id)) .tuples()) +def list_robot_permissions(robot_name): + return (RepositoryPermission.select(RepositoryPermission, User, Repository) + .join(Repository) + .join(Visibility) + .switch(RepositoryPermission) + .join(Role) + .switch(RepositoryPermission) + .join(User) + .where(User.username == robot_name, User.robot == True)) def convert_user_to_organization(user, admin_user): # Change the user to an organization. diff --git a/endpoints/api/prototype.py b/endpoints/api/prototype.py index 343913c3a..de0c97483 100644 --- a/endpoints/api/prototype.py +++ b/endpoints/api/prototype.py @@ -7,6 +7,7 @@ from auth.permissions import AdministerOrganizationPermission from auth.auth_context import get_authenticated_user from auth import scopes from data import model +from app import avatar def prototype_view(proto, org_members): @@ -16,6 +17,7 @@ def prototype_view(proto, org_members): 'is_robot': user.robot, 'kind': 'user', 'is_org_member': user.robot or user.username in org_members, + 'avatar': avatar.get_data_for_user(user) } if proto.delegate_user: @@ -24,6 +26,7 @@ def prototype_view(proto, org_members): delegate_view = { 'name': proto.delegate_team.name, 'kind': 'team', + 'avatar': avatar.get_data_for_team(proto.delegate_team) } return { diff --git a/endpoints/api/robot.py b/endpoints/api/robot.py index b7614a356..7d5136951 100644 --- a/endpoints/api/robot.py +++ b/endpoints/api/robot.py @@ -6,12 +6,24 @@ from auth.auth_context import get_authenticated_user from auth import scopes from data import model from util.names import format_robot_username +from flask import abort -def robot_view(name, token): +def robot_view(name, token, count=None): return { 'name': name, 'token': token, + 'permission_count': count + } + + +def permission_view(permission): + return { + 'repository': { + 'name': permission.repository.name, + 'is_public': permission.repository.visibility.name == 'public' + }, + 'role': permission.role.name } @@ -26,7 +38,7 @@ class UserRobotList(ApiResource): user = get_authenticated_user() robots = model.list_entity_robot_tuples(user.username) return { - 'robots': [robot_view(name, password) for name, password in robots] + 'robots': [robot_view(name, password, count) for name, password, count in robots] } @@ -75,7 +87,7 @@ class OrgRobotList(ApiResource): if permission.can(): robots = model.list_entity_robot_tuples(orgname) return { - 'robots': [robot_view(name, password) for name, password in robots] + 'robots': [robot_view(name, password, count) for name, password, count in robots] } raise Unauthorized() @@ -125,6 +137,47 @@ class OrgRobot(ApiResource): raise Unauthorized() +@resource('/v1/user/robots//permissions') +@path_param('robot_shortname', 'The short name for the robot, without any user or organization prefix') +@internal_only +class UserRobotPermissions(ApiResource): + """ Resource for listing the permissions a user's robot has in the system. """ + @require_user_admin + @nickname('getUserRobotPermissions') + def get(self, robot_shortname): + """ Returns the list of repository permissions for the user's robot. """ + parent = get_authenticated_user() + robot, password = model.get_robot(robot_shortname, parent) + permissions = model.list_robot_permissions(robot.username) + + return { + 'permissions': [permission_view(permission) for permission in permissions] + } + + +@resource('/v1/organization//robots//permissions') +@path_param('orgname', 'The name of the organization') +@path_param('robot_shortname', 'The short name for the robot, without any user or organization prefix') +@related_user_resource(UserRobotPermissions) +class OrgRobotPermissions(ApiResource): + """ Resource for listing the permissions an org's robot has in the system. """ + @require_user_admin + @nickname('getOrgRobotPermissions') + def get(self, orgname, robot_shortname): + """ Returns the list of repository permissions for the org's robot. """ + permission = AdministerOrganizationPermission(orgname) + if permission.can(): + parent = model.get_organization(orgname) + robot, password = model.get_robot(robot_shortname, parent) + permissions = model.list_robot_permissions(robot.username) + + return { + 'permissions': [permission_view(permission) for permission in permissions] + } + + abort(403) + + @resource('/v1/user/robots//regenerate') @path_param('robot_shortname', 'The short name for the robot, without any user or organization prefix') @internal_only diff --git a/endpoints/api/user.py b/endpoints/api/user.py index 6766c0fe9..177bd58af 100644 --- a/endpoints/api/user.py +++ b/endpoints/api/user.py @@ -175,8 +175,8 @@ class User(ApiResource): 'description': 'The user\'s email address', }, 'avatar': { - 'type': 'string', - 'description': 'Avatar hash representing the user\'s icon' + 'type': 'object', + 'description': 'Avatar data representing the user\'s icon' }, 'organizations': { 'type': 'array', diff --git a/static/css/core-ui.css b/static/css/core-ui.css index 435797185..7e614d998 100644 --- a/static/css/core-ui.css +++ b/static/css/core-ui.css @@ -818,6 +818,17 @@ width: 30px; } +.co-table td.caret-col { + width: 10px; + padding-left: 6px; + padding-right: 0px; + color: #aaa; +} + +.co-table td.caret-col i.fa { + cursor: pointer; +} + .co-table .add-row-spacer td { padding: 5px; } diff --git a/static/css/directives/ui/robots-manager.css b/static/css/directives/ui/robots-manager.css new file mode 100644 index 000000000..419246bff --- /dev/null +++ b/static/css/directives/ui/robots-manager.css @@ -0,0 +1,86 @@ +.robots-manager-element .manager-header { + margin-bottom: 10px; + padding-bottom: 10px; + border-bottom: 1px solid #eee; +} + +.robots-manager-element .manager-header h3 { + margin-bottom: 10px; +} + +.robots-manager-element .robot a { + font-size: 16px; + cursor: pointer; +} + +.robots-manager-element .robot .prefix { + color: #aaa; +} + +.robots-manager-element .robot i { + margin-right: 10px; +} + +.robots-manager-element .popup-input-button i.fa { + margin-right: 4px; +} + +.robots-manager-element .empty { + color: #ccc; +} + +.robots-manager-element tr.open td { + border-bottom: 1px solid transparent; +} + +.robots-manager-element .permissions-table-wrapper { + margin-left: 0px; + border-left: 2px solid #ccc; + padding-left: 20px; +} + +.robots-manager-element .permissions-table tbody tr:last-child td { + border-bottom: 0px; +} + +.robots-manager-element .permissions-display-row { + position: relative; + padding-bottom: 20px; +} + +.robots-manager-element .permissions-display-row td:first-child { + min-width: 300px; +} + +.robots-manager-element .repo-circle { + color: #999; + display: inline-block; + position: relative; + background: #eee; + padding: 4px; + border-radius: 50%; + display: inline-block; + width: 46px; + height: 46px; + margin-right: 6px; +} + +.robots-manager-element .repo-circle .fa-hdd-o { + font-size: 1.7em; +} + +.robots-manager-element .repo-circle.no-background .fa-hdd-o { + font-size: 1.7em; +} + +.robots-manager-element .repo-circle .fa-lock { + width: 16px; + height: 16px; + line-height: 16px; + font-size: 12px !important; +} + +.robots-manager-element .repo-circle.no-background .fa-lock { + bottom: 5px; + right: 2px; +} \ No newline at end of file diff --git a/static/css/pages/org-view.css b/static/css/pages/org-view.css index f922343c1..1efd78fd1 100644 --- a/static/css/pages/org-view.css +++ b/static/css/pages/org-view.css @@ -6,4 +6,20 @@ .org-view h3 { margin-bottom: 20px; margin-top: 0px; +} + +.org-view .section-description-header { + padding-left: 40px; + position: relative; + margin-bottom: 20px; +} + +.org-view .section-description-header:before { + font-family: FontAwesome; + content: "\f05a"; + position: absolute; + top: 2px; + left: 6px; + font-size: 27px; + color: #888; } \ No newline at end of file diff --git a/static/css/quay.css b/static/css/quay.css index 70714d7e0..c61321b0e 100644 --- a/static/css/quay.css +++ b/static/css/quay.css @@ -525,27 +525,6 @@ i.toggle-icon:hover { visibility: hidden; } -.robots-manager-element { - max-width: 800px; -} - -.robots-manager-element .alert { - margin-bottom: 20px; -} - -.robots-manager-element .robot a { - font-size: 16px; - cursor: pointer; -} - -.robots-manager-element .robot .prefix { - color: #aaa; -} - -.robots-manager-element .robot i { - margin-right: 10px; -} - .logs-view-element .header { padding-bottom: 10px; border-bottom: 1px solid #eee; diff --git a/static/directives/robots-manager.html b/static/directives/robots-manager.html index b4bc89cab..e62cea54e 100644 --- a/static/directives/robots-manager.html +++ b/static/directives/robots-manager.html @@ -1,32 +1,102 @@
-
-
Robot accounts allow for delegating access in multiple repositories to role-based accounts that you manage
+
-
- - Create Robot Account - +
+
+ + Create Robot Account + +
+

Robot Accounts

- +
+ Robot Accounts are named tokens that can be granted permissions on multiple repositories + under this organizationuser namespace. They are typically used in environments where credentials will + be shared, such as deployment systems. +
+ +
+
No robot accounts defined.
+
+ Click the "Create Robot Account" button above to create a robot account. +
+
+ +
- - + + + + - - - - + + + + + + + + + + +
Robot Account NameRobot Account NameRepository Permissions
- - - {{ getPrefix(robotInfo.name) }}+{{ getShortenedName(robotInfo.name) }} - - - -
+ + + + + + + {{ getPrefix(robotInfo.name) }}+{{ getShortenedName(robotInfo.name) }} + + + (No permissions on any repositories) + + Permissions on + {{ robotInfo.permission_count }} + repository + repositories + + + + + + View Credentials + + + Delete Robot {{ robotInfo.name }} + + +
+ +
+ + + + + + + + + + +
RepositoryPermission
+ + {{ getPrefix(robotInfo.name) }}/{{ permission.repository.name }} + +
+ +
+
+
+
diff --git a/static/directives/role-group.html b/static/directives/role-group.html index f3b53ad43..8cf0f6ba2 100644 --- a/static/directives/role-group.html +++ b/static/directives/role-group.html @@ -1,5 +1,6 @@
+ ng-class="(currentRole == role.id) ? ('active btn-' + role.kind) : 'btn-default'" + ng-disabled="readOnly">{{ role.title }}
diff --git a/static/js/directives/ui/repository-permissions-table.js b/static/js/directives/ui/repository-permissions-table.js index 95ce43f32..519d12fd7 100644 --- a/static/js/directives/ui/repository-permissions-table.js +++ b/static/js/directives/ui/repository-permissions-table.js @@ -13,6 +13,7 @@ angular.module('quay').directive('repositoryPermissionsTable', function () { 'repository': '=repository' }, controller: function($scope, $element, ApiService, Restangular, UtilService) { + // TODO(jschorr): move this to a service. $scope.roles = [ { 'id': 'read', 'title': 'Read', 'kind': 'success' }, { 'id': 'write', 'title': 'Write', 'kind': 'success' }, diff --git a/static/js/directives/ui/robots-manager.js b/static/js/directives/ui/robots-manager.js index 8ebf04337..9eb3dcf54 100644 --- a/static/js/directives/ui/robots-manager.js +++ b/static/js/directives/ui/robots-manager.js @@ -14,11 +14,36 @@ angular.module('quay').directive('robotsManager', function () { }, controller: function($scope, $element, ApiService, $routeParams, CreateService) { $scope.ROBOT_PATTERN = ROBOT_PATTERN; + + // TODO(jschorr): move this to a service. + $scope.roles = [ + { 'id': 'read', 'title': 'Read', 'kind': 'success' }, + { 'id': 'write', 'title': 'Write', 'kind': 'success' }, + { 'id': 'admin', 'title': 'Admin', 'kind': 'primary' } + ]; + $scope.robots = null; $scope.loading = false; $scope.shownRobot = null; $scope.showRobotCounter = 0; + var loadRobotPermissions = function(info) { + var shortName = $scope.getShortenedName(info.name); + info.loading_permissions = true; + ApiService.getRobotPermissions($scope.organization, null, {'robot_shortname': shortName}).then(function(resp) { + info.permissions = resp.permissions; + info.loading_permissions = false; + }, ApiService.errorDisplay('Could not load robot permissions')); + }; + + $scope.showPermissions = function(robotInfo) { + robotInfo.showing_permissions = !robotInfo.showing_permissions; + + if (robotInfo.showing_permissions) { + loadRobotPermissions(robotInfo); + } + }; + $scope.regenerateToken = function(username) { if (!username) { return; } diff --git a/static/js/directives/ui/role-group.js b/static/js/directives/ui/role-group.js index d8ca75873..66fd0629c 100644 --- a/static/js/directives/ui/role-group.js +++ b/static/js/directives/ui/role-group.js @@ -12,6 +12,7 @@ angular.module('quay').directive('roleGroup', function () { scope: { 'roles': '=roles', 'currentRole': '=currentRole', + 'readOnly': '=readOnly', 'roleChanged': '&roleChanged' }, controller: function($scope, $element) { diff --git a/static/partials/org-view.html b/static/partials/org-view.html index 25f062795..44f000eac 100644 --- a/static/partials/org-view.html +++ b/static/partials/org-view.html @@ -18,10 +18,10 @@ - + - + + tab-init="showLogs()" ng-show="isAdmin"> + tab-init="showApplications()" ng-show="isAdmin"> + ng-show="isAdmin">
@@ -65,7 +65,6 @@
-

Robot Accounts

diff --git a/test/test_api_security.py b/test/test_api_security.py index df01fe8c9..062d8dcae 100644 --- a/test/test_api_security.py +++ b/test/test_api_security.py @@ -17,7 +17,8 @@ from endpoints.api.image import RepositoryImageChanges, RepositoryImage, Reposit from endpoints.api.build import (FileDropResource, RepositoryBuildStatus, RepositoryBuildLogs, RepositoryBuildList, RepositoryBuildResource) from endpoints.api.robot import (UserRobotList, OrgRobot, OrgRobotList, UserRobot, - RegenerateOrgRobot, RegenerateUserRobot) + RegenerateOrgRobot, RegenerateUserRobot, UserRobotPermissions, + OrgRobotPermissions) from endpoints.api.trigger import (BuildTriggerActivate, BuildTriggerSources, BuildTriggerSubdirs, TriggerBuildList, ActivateBuildTrigger, BuildTrigger, @@ -3335,10 +3336,28 @@ class TestRegenerateOrgRobot(ApiTestCase): self._run_test('POST', 400, 'devtable', None) -class TestOrganizationBuynlarge(ApiTestCase): +class TestUserRobotPermissions(ApiTestCase): def setUp(self): ApiTestCase.setUp(self) - self._set_url(Organization, orgname="buynlarge") + self._set_url(UserRobotPermissions, robot_shortname="robotname") + + def test_get_anonymous(self): + self._run_test('GET', 401, None, None) + + def test_get_freshuser(self): + self._run_test('GET', 400, 'freshuser', None) + + def test_get_reader(self): + self._run_test('GET', 400, 'reader', None) + + def test_get_devtable(self): + self._run_test('GET', 400, 'devtable', None) + + +class TestOrgRobotPermissions(ApiTestCase): + def setUp(self): + ApiTestCase.setUp(self) + self._set_url(OrgRobotPermissions, orgname="buynlarge", robot_shortname="robotname") def test_get_anonymous(self): self._run_test('GET', 401, None, None) @@ -3346,6 +3365,24 @@ class TestOrganizationBuynlarge(ApiTestCase): 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', 400, 'devtable', None) + + +class TestOrganizationBuynlarge(ApiTestCase): + def setUp(self): + ApiTestCase.setUp(self) + self._set_url(Organization, orgname="buynlarge") + + def test_get_anonymous(self): + self._run_test('GET', 200, None, None) + + def test_get_freshuser(self): + self._run_test('GET', 200, 'freshuser', None) + def test_get_reader(self): self._run_test('GET', 200, 'reader', None) diff --git a/test/test_api_usage.py b/test/test_api_usage.py index 402aa15c5..3460465f6 100644 --- a/test/test_api_usage.py +++ b/test/test_api_usage.py @@ -552,12 +552,12 @@ class TestGetOrganization(ApiTestCase): def test_unknownorg(self): self.login(ADMIN_ACCESS_USER) self.getResponse(Organization, params=dict(orgname='notvalid'), - expected_code=403) + expected_code=404) def test_cannotaccess(self): self.login(NO_ACCESS_USER) self.getResponse(Organization, params=dict(orgname=ORGANIZATION), - expected_code=403) + expected_code=200) def test_getorganization(self): self.login(READ_ACCESS_USER)