diff --git a/auth/auth.py b/auth/auth.py index 2d77d6094..a20e20129 100644 --- a/auth/auth.py +++ b/auth/auth.py @@ -48,6 +48,20 @@ def process_basic_auth(auth): except model.DataModelException: logger.debug('Invalid token: %s' % credentials[1]) + elif '+' in credentials[0]: + logger.debug('Trying robot auth with credentials %s' % str(credentials)) + # Use as robot auth + try: + robot = model.verify_robot(credentials[0], credentials[1]) + logger.debug('Successfully validated robot: %s' % credentials[0]) + ctx = _request_ctx_stack.top + ctx.authenticated_user = robot + + identity_changed.send(app, identity=Identity(robot.username, 'username')) + return + except model.InvalidRobotException: + logger.debug('Invalid robot or password for robot: %s' % credentials[0]) + else: authenticated = model.verify_user(credentials[0], credentials[1]) diff --git a/data/database.py b/data/database.py index 1a8291d5a..9d1744c0a 100644 --- a/data/database.py +++ b/data/database.py @@ -22,6 +22,14 @@ def close_db(exc): app.teardown_request(close_db) +def random_string_generator(length=16): + def random_string(): + random = SystemRandom() + return ''.join([random.choice(string.ascii_uppercase + string.digits) + for _ in range(length)]) + return random_string + + class BaseModel(Model): class Meta: database = db @@ -30,10 +38,12 @@ class BaseModel(Model): class User(BaseModel): username = CharField(unique=True, index=True) password_hash = CharField(null=True) - email = CharField(unique=True, index=True) + email = CharField(unique=True, index=True, + default=random_string_generator(length=64)) verified = BooleanField(default=False) stripe_id = CharField(index=True, null=True) organization = BooleanField(default=False, index=True) + robot = BooleanField(default=False, index=True) invoice_email = BooleanField(default=False) @@ -123,14 +133,6 @@ class RepositoryPermission(BaseModel): ) -def random_string_generator(length=16): - def random_string(): - random = SystemRandom() - return ''.join([random.choice(string.ascii_uppercase + string.digits) - for x in range(length)]) - return random_string - - class Webhook(BaseModel): public_id = CharField(default=random_string_generator(length=64), unique=True, index=True) diff --git a/data/model.py b/data/model.py index 28c910d28..9fe51db65 100644 --- a/data/model.py +++ b/data/model.py @@ -6,6 +6,7 @@ import json from database import * from util.validation import * +from util.names import format_robot_username logger = logging.getLogger(__name__) @@ -27,6 +28,10 @@ class InvalidOrganizationException(DataModelException): pass +class InvalidRobotException(DataModelException): + pass + + class InvalidTeamException(DataModelException): pass @@ -60,7 +65,7 @@ def create_user(username, password, email): try: existing = User.get((User.username == username) | (User.email == email)) - logger.debug('Existing user with same username or email.') + logger.info('Existing user with same username or email.') # A user already exists with either the same username or email if existing.username == username: @@ -104,6 +109,65 @@ def create_organization(name, email, creating_user): raise InvalidOrganizationException('Invalid organization name: %s' % name) +def create_robot(robot_shortname, parent): + if not validate_username(robot_shortname): + raise InvalidRobotException('The name for the robot \'%s\' is invalid.' % + robot_shortname) + + username = format_robot_username(parent.username, robot_shortname) + + try: + User.get(User.username == username) + + msg = 'Existing robot with name: %s' % username + logger.info(msg) + raise InvalidRobotException(msg) + + except User.DoesNotExist: + pass + + try: + created = User.create(username=username, robot=True) + + service = LoginService.get(name='quayrobot') + password = created.email + FederatedLogin.create(user=created, service=service, + service_ident=password) + + return created, password + except Exception as ex: + raise DataModelException(ex.message) + + +def verify_robot(robot_username, password): + joined = User.select().join(FederatedLogin).join(LoginService) + found = list(joined.where(FederatedLogin.service_ident == password, + LoginService.name == 'quayrobot', + User.username == robot_username)) + if not found: + msg = ('Could not find robot with username: %s and supplied password.' % + robot_username) + raise InvalidRobotException(msg) + + return found[0] + + +def delete_robot(robot_username): + try: + robot = User.get(username=robot_username, robot=True) + robot.delete_instance(recursive=True, delete_nullable=True) + except User.DoesNotExist: + raise InvalidRobotException('Could not find robot with username: %s' % + robot_username) + + +def list_entity_robots(entity_name): + selected = User.select(User.username, FederatedLogin.service_ident) + joined = selected.join(FederatedLogin) + return joined.where(User.robot == True, + User.username ** (entity_name + '+%')).tuples() + + def convert_user_to_organization(user, admin_user): # Change the user to an organization. user.organization = True @@ -123,6 +187,7 @@ def convert_user_to_organization(user, admin_user): return user + def create_team(name, org, team_role_name, description=''): if not validate_username(name): raise InvalidTeamException('Invalid team name: %s' % name) @@ -136,16 +201,16 @@ def create_team(name, org, team_role_name, description=''): description=description) -def __get_user_admin_teams(org_name, team_name, username): - Org = User.alias() - user_teams = Team.select().join(TeamMember).join(User) - with_org = user_teams.switch(Team).join(Org, - on=(Org.id == Team.organization)) - with_role = with_org.switch(Team).join(TeamRole) - admin_teams = with_role.where(User.username == username, - Org.username == org_name, - TeamRole.name == 'admin') - return admin_teams +def __get_user_admin_teams(org_name, username): + Org = User.alias() + user_teams = Team.select().join(TeamMember).join(User) + with_org = user_teams.switch(Team).join(Org, + on=(Org.id == Team.organization)) + with_role = with_org.switch(Team).join(TeamRole) + admin_teams = with_role.where(User.username == username, + Org.username == org_name, + TeamRole.name == 'admin') + return admin_teams def remove_team(org_name, team_name, removed_by_username): @@ -228,15 +293,16 @@ def set_team_org_permission(team, team_role_name, set_by_username): def create_federated_user(username, email, service_name, service_id): - new_user = create_user(username, None, email) - new_user.verified = True - new_user.save() + new_user = create_user(username, None, email) + new_user.verified = True + new_user.save() - service = LoginService.get(LoginService.name == service_name) - federated_user = FederatedLogin.create(user=new_user, service=service, - service_ident=service_id) + service = LoginService.get(LoginService.name == service_name) + FederatedLogin.create(user=new_user, service=service, + service_ident=service_id) + + return new_user - return new_user def verify_federated_login(service_name, service_id): selected = FederatedLogin.select(FederatedLogin, User) @@ -313,11 +379,19 @@ def get_matching_teams(team_prefix, organization): return query.limit(10) -def get_matching_users(username_prefix, organization=None): +def get_matching_users(username_prefix, robot_namespace=None, + organization=None): Org = User.alias() - users_no_orgs = (User.username ** (username_prefix + '%') & - (User.organization == False)) - query = User.select(User.username, Org.username).where(users_no_orgs) + direct_user_query = (User.username ** (username_prefix + '%') & + (User.organization == False) & (User.robot == False)) + + if robot_namespace: + robot_prefix = format_robot_username(robot_namespace, username_prefix) + direct_user_query = (direct_user_query | + (User.username ** (robot_prefix + '%') & + (User.robot == True))) + + query = User.select(User.username, Org.username, User.robot).where(direct_user_query) if organization: with_team = query.join(TeamMember, JOIN_LEFT_OUTER).join(Team, @@ -330,6 +404,7 @@ def get_matching_users(username_prefix, organization=None): class MatchingUserResult(object): def __init__(self, *args): self.username = args[0] + self.is_robot = args[2] if organization: self.is_org_member = (args[1] == organization.username) else: @@ -552,7 +627,7 @@ def get_all_repo_teams(namespace_name, repository_name): def get_all_repo_users(namespace_name, repository_name): - select = RepositoryPermission.select(User.username, Role.name, + select = RepositoryPermission.select(User.username, User.robot, Role.name, RepositoryPermission) with_user = select.join(User) with_role = with_user.switch(RepositoryPermission).join(Role) diff --git a/endpoints/api.py b/endpoints/api.py index 5568f1465..5f929aca2 100644 --- a/endpoints/api.py +++ b/endpoints/api.py @@ -15,7 +15,7 @@ from data.queue import dockerfile_build_queue from data.plans import USER_PLANS, BUSINESS_PLANS, get_plan from app import app from util.email import send_confirmation_email, send_recovery_email -from util.names import parse_repository_name +from util.names import parse_repository_name, format_robot_username from util.gravatar import compute_hash from auth.permissions import (ReadRepositoryPermission, ModifyRepositoryPermission, @@ -44,6 +44,10 @@ def api_login_required(f): current_user.db_user().organization): abort(401) + if (current_user and current_user.db_user() and + current_user.db_user().robot): + abort(401) + return f(*args, **kwargs) return decorated_view @@ -260,20 +264,26 @@ def get_matching_users(prefix): def get_matching_entities(prefix): teams = [] - organization_name = request.args.get('organization', None) + namespace_name = request.args.get('namespace', None) + robot_namespace = None organization = None - if organization_name: - permission = OrganizationMemberPermission(organization_name) + try: + organization = model.get_organization(namespace_name) + + # namespace name was an org + permission = OrganizationMemberPermission(namespace_name) if permission.can(): - try: - organization = model.get_organization(organization_name) - except model.InvalidOrganizationException: - pass + robot_namespace = namespace_name - if organization: - teams = model.get_matching_teams(prefix, organization) + if request.args.get('includeTeams', False): + teams = model.get_matching_teams(prefix, organization) - users = model.get_matching_users(prefix, organization) + except model.InvalidOrganizationException: + # namespace name was a user + if current_user.db_user().username == namespace_name: + robot_namespace = namespace_name + + users = model.get_matching_users(prefix, robot_namespace, organization) def entity_team_view(team): result = { @@ -287,10 +297,11 @@ def get_matching_entities(prefix): user_json = { 'name': user.username, 'kind': 'user', + 'is_robot': user.is_robot, } if user.is_org_member is not None: - user_json['is_org_member'] = user.is_org_member + user_json['is_org_member'] = user.is_robot or user.is_org_member return user_json @@ -449,7 +460,8 @@ def get_organization_private_allowed(orgname): def member_view(member): return { - 'username': member.username + 'username': member.username, + 'is_robot': member.robot, } @@ -911,8 +923,13 @@ def role_view(repo_perm_obj): } -def wrap_role_view_org(role_json, org_member): - role_json['is_org_member'] = org_member +def wrap_role_view_user(role_json, user): + role_json['is_robot'] = user.robot + return role_json + + +def wrap_role_view_org(role_json, user, org_members): + role_json['is_org_member'] = user.robot or user.username in org_members return role_json @@ -1021,22 +1038,30 @@ def list_repo_team_permissions(namespace, repository): def list_repo_user_permissions(namespace, repository): permission = AdministerRepositoryPermission(namespace, repository) if permission.can(): - # Determine how to wrap the permissions - role_view_func = role_view + # Lookup the organization (if any). + org = None try: - model.get_organization(namespace) # Will raise an error if not org - org_members = model.get_organization_member_set(namespace) - def wrapped_role_view(repo_perm): - unwrapped = role_view(repo_perm) - return wrap_role_view_org(unwrapped, - repo_perm.user.username in org_members) - - role_view_func = wrapped_role_view - + org = model.get_organization(namespace) # Will raise an error if not org except model.InvalidOrganizationException: # This repository isn't under an org pass + # Determine how to wrap the role(s). + def wrapped_role_view(repo_perm): + return wrap_role_view_user(role_view(repo_perm), repo_perm.user) + + role_view_func = wrapped_role_view + + if org: + org_members = model.get_organization_member_set(namespace) + current_func = role_view_func + + def wrapped_role_org_view(repo_perm): + return wrap_role_view_org(current_func(repo_perm), repo_perm.user, org_members) + + role_view_func = wrapped_role_org_view + + # Load and return the permissions. repo_perms = model.get_all_repo_users(namespace, repository) return jsonify({ 'permissions': {perm.user.username: role_view_func(perm) @@ -1056,13 +1081,12 @@ def get_user_permissions(namespace, repository, username): permission = AdministerRepositoryPermission(namespace, repository) if permission.can(): perm = model.get_user_reponame_permission(username, namespace, repository) - perm_view = role_view(perm) + perm_view = wrap_role_view_user(role_view(perm), perm.user) try: model.get_organization(namespace) org_members = model.get_organization_member_set(namespace) - perm_view = wrap_role_view_org(perm_view, - perm.user.username in org_members) + perm_view = wrap_role_view_org(perm_view, perm.user, org_members) except model.InvalidOrganizationException: # This repository is not part of an organization pass @@ -1101,13 +1125,12 @@ def change_user_permissions(namespace, repository, username): perm = model.set_user_repo_permission(username, namespace, repository, new_permission['role']) - perm_view = role_view(perm) + perm_view = wrap_role_view_user(role_view(perm), perm.user) try: model.get_organization(namespace) org_members = model.get_organization_member_set(namespace) - perm_view = wrap_role_view_org(perm_view, - perm.user.username in org_members) + perm_view = wrap_role_view_org(perm_view, perm.user, org_members) except model.InvalidOrganizationException: # This repository is not part of an organization pass @@ -1519,3 +1542,78 @@ def get_org_subscription(orgname): }) abort(403) + + +def robot_view(name, token): + return { + 'name': name, + 'token': token, + } + + +@app.route('/api/user/robots', methods=['GET']) +@api_login_required +def get_user_robots(): + user = current_user.db_user() + robots = model.list_entity_robots(user.username) + return jsonify({ + 'robots': [robot_view(name, password) for name, password in robots] + }) + + +@app.route('/api/organization//robots', methods=['GET']) +@api_login_required +def get_org_robots(orgname): + permission = AdministerOrganizationPermission(orgname) + if permission.can(): + robots = model.list_entity_robots(orgname) + return jsonify({ + 'robots': [robot_view(name, password) for name, password in robots] + }) + + abort(403) + + +@app.route('/api/user/robots/', methods=['PUT']) +@api_login_required +def create_robot(robot_shortname): + parent = current_user.db_user() + robot, password = model.create_robot(robot_shortname, parent) + resp = jsonify(robot_view(robot.username, password)) + resp.status_code = 201 + return resp + + +@app.route('/api/organization//robots/', + methods=['PUT']) +@api_login_required +def create_org_robot(orgname, robot_shortname): + permission = AdministerOrganizationPermission(orgname) + if permission.can(): + parent = model.get_organization(orgname) + robot, password = model.create_robot(robot_shortname, parent) + resp = jsonify(robot_view(robot.username, password)) + resp.status_code = 201 + return resp + + abort(403) + + +@app.route('/api/user/robots/', methods=['DELETE']) +@api_login_required +def delete_robot(robot_shortname): + parent = current_user.db_user() + model.delete_robot(format_robot_username(parent.username, robot_shortname)) + return make_response('No Content', 204) + + +@app.route('/api/organization//robots/', + methods=['DELETE']) +@api_login_required +def delete_org_robot(orgname, robot_shortname): + permission = AdministerOrganizationPermission(orgname) + if permission.can(): + model.delete_robot(format_robot_username(orgname, robot_shortname)) + return make_response('No Content', 204) + + abort(403) diff --git a/endpoints/index.py b/endpoints/index.py index 207ace697..13525d5e9 100644 --- a/endpoints/index.py +++ b/endpoints/index.py @@ -62,6 +62,13 @@ def create_user(): except model.InvalidTokenException: abort(401) + elif '+' in username: + try: + model.verify_robot(username, password) + return make_response('Verified', 201) + except model.InvalidRobotException: + abort(401) + existing_user = model.get_user(username) if existing_user: verified = model.verify_user(username, password) diff --git a/initdb.py b/initdb.py index aebdbe684..e810d8ab7 100644 --- a/initdb.py +++ b/initdb.py @@ -109,6 +109,7 @@ def initialize_database(): Visibility.create(name='public') Visibility.create(name='private') LoginService.create(name='github') + LoginService.create(name='quayrobot') def wipe_database(): @@ -131,6 +132,8 @@ def populate_database(): new_user_1.verified = True new_user_1.save() + model.create_robot('dtrobot', new_user_1) + new_user_2 = model.create_user('public', 'password', 'jacob.moshenko@gmail.com') new_user_2.verified = True @@ -187,6 +190,8 @@ def populate_database(): org.stripe_id = TEST_STRIPE_ID org.save() + model.create_robot('neworgrobot', org) + owners = model.get_organization_team('buynlarge', 'owners') owners.description = 'Owners have unfetterd access across the entire org.' owners.save() diff --git a/static/css/quay.css b/static/css/quay.css index 8007f1ad6..b63bbea29 100644 --- a/static/css/quay.css +++ b/static/css/quay.css @@ -3,6 +3,11 @@ margin: 0; } +#input-box { + padding: 4px; + font-size: 14px; +} + html, body { height: 100%; } @@ -16,6 +21,45 @@ html, body { border-bottom: 1px dashed #aaa; } +.docker-auth-dialog .token-dialog-body .well { + margin-bottom: 0px; +} + +.docker-auth-dialog .token-view { + background: transparent; + display: block; + border: 0px transparent; + font-size: 12px; + width: 100%; +} + +.docker-auth-dialog .download-cfg { + float: left; + padding-top: 6px; + font-size: 16px; +} + +.docker-auth-dialog .download-cfg .fa-download { + margin-right: 10px; + font-size: 25px; + vertical-align: middle; +} + +#copyClipboard { + cursor: pointer; +} + +#copyClipboard.zeroclipboard-is-hover { + background: #428bca; + color: white; +} + +#clipboardCopied.hovering { + position: absolute; + right: 0px; + top: 40px; +} + .content-container { padding-bottom: 70px; } @@ -43,6 +87,27 @@ html, body { 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; +} + .billing-options-element .current-card { font-size: 16px; margin-bottom: 20px; @@ -957,21 +1022,6 @@ p.editable:hover i { width: 300px; } -.repo #copyClipboard { - cursor: pointer; -} - -.repo #copyClipboard.zeroclipboard-is-hover { - background: #428bca; - color: white; -} - -.repo #clipboardCopied.hovering { - position: absolute; - right: 0px; - top: 40px; -} - .repo-image-view .id-container { display: inline-block; margin-top: 10px; @@ -1014,7 +1064,7 @@ p.editable:hover i { margin-top: 28px; } -.repo #clipboardCopied { +#clipboardCopied { font-size: 0.8em; display: inline-block; margin-right: 10px; @@ -1025,7 +1075,7 @@ p.editable:hover i { border-radius: 4px; } -.repo #clipboardCopied.animated { +#clipboardCopied.animated { -webkit-animation: fadeOut 4s ease-in-out 0s 1 forwards; -moz-animation: fadeOut 4s ease-in-out 0s 1 forwards; -ms-animation: fadeOut 4s ease-in-out 0s 1 forwards; @@ -1112,21 +1162,17 @@ p.editable:hover i { width: 300px; } -.repo-admin .token-dialog-body .well { - margin-bottom: 0px; -} - -.repo-admin .token-view { - background: transparent; - display: block; - border: 0px transparent; - font-size: 12px; - width: 100%; -} - .repo-admin .panel { display: inline-block; - width: 620px; + width: 720px; +} + +.repo-admin .prefix { + color: #aaa; +} + +.repo-admin .admin-search { + padding-top: 20px; } .repo-admin .user i.fa-user { @@ -1134,6 +1180,11 @@ p.editable:hover i { margin-right: 7px; } +.repo-admin .user i.fa-wrench { + margin-left: 1px; + margin-right: 5px; +} + .repo-admin .team i.fa-group { margin-right: 4px; } @@ -1265,18 +1316,6 @@ p.editable:hover i { white-space: nowrap; } -.repo .download-cfg { - float: left; - padding-top: 6px; - font-size: 16px; -} - -.repo .download-cfg .icon-download { - margin-right: 10px; - font-size: 25px; - vertical-align: middle; -} - .navbar-nav > li > .user-dropdown { padding-top: 9px; padding-bottom: 9px; @@ -1732,16 +1771,16 @@ p.editable:hover i { margin-right: 4px; } -.org-admin #members .side-controls { +.side-controls { float: right; } -.org-admin #members .result-count { +.side-controls .result-count { display: inline-block; margin-right: 10px; } -.org-admin #members .filter-input { +.side-controls .filter-input { display: inline-block; } diff --git a/static/directives/docker-auth-dialog.html b/static/directives/docker-auth-dialog.html new file mode 100644 index 000000000..dcb71a25b --- /dev/null +++ b/static/directives/docker-auth-dialog.html @@ -0,0 +1,30 @@ + + diff --git a/static/directives/popup-input-button.html b/static/directives/popup-input-button.html new file mode 100644 index 000000000..005c037bc --- /dev/null +++ b/static/directives/popup-input-button.html @@ -0,0 +1,5 @@ + + diff --git a/static/directives/popup-input-dialog.html b/static/directives/popup-input-dialog.html new file mode 100644 index 000000000..6632b1999 --- /dev/null +++ b/static/directives/popup-input-dialog.html @@ -0,0 +1,4 @@ +
+ +
diff --git a/static/directives/robots-manager.html b/static/directives/robots-manager.html new file mode 100644 index 000000000..ef787a3dd --- /dev/null +++ b/static/directives/robots-manager.html @@ -0,0 +1,40 @@ +
+ +
Robot accounts allow for delegating access in multiple repositories to role-based accounts that you manage
+ +
+
+ + Create Robot Account + +
+ + + + + + + + + + + +
Robot Account Name
+ + + {{ getPrefix(robotInfo.name) }}+{{ getShortenedName(robotInfo.name) }} + + + + + + +
+
+ +
+ {{ shownRobot.name }} +
+
diff --git a/static/js/app.js b/static/js/app.js index bff77132d..7ba5b909e 100644 --- a/static/js/app.js +++ b/static/js/app.js @@ -550,7 +550,7 @@ quayApp.directive('plansTable', function () { priority: 0, templateUrl: '/static/directives/plans-table.html', replace: false, - transclude: true, + transclude: false, restrict: 'C', scope: { 'plans': '=plans', @@ -566,6 +566,208 @@ quayApp.directive('plansTable', function () { }); +quayApp.directive('dockerAuthDialog', function () { + var directiveDefinitionObject = { + priority: 0, + templateUrl: '/static/directives/docker-auth-dialog.html', + replace: false, + transclude: true, + restrict: 'C', + scope: { + 'username': '=username', + 'token': '=token', + 'shown': '=shown', + 'counter': '=counter' + }, + controller: function($scope, $element, Restangular) { + $scope.isDownloadSupported = function() { + try { return !!new Blob(); } catch(e){} + return false; + }; + + $scope.downloadCfg = function() { + var auth = $.base64.encode($scope.username + ":" + $scope.token); + config = { + "https://quay.io/v1/": { + "auth": auth, + "email": "" + } + }; + + var file = JSON.stringify(config, null, ' '); + var blob = new Blob([file]); + saveAs(blob, '.dockercfg'); + }; + + var show = function(r) { + if (!$scope.shown || !$scope.username || !$scope.token) { + $('#dockerauthmodal').modal('hide'); + return; + } + + $('#copyClipboard').clipboardCopy(); + $('#dockerauthmodal').modal({}); + }; + + $scope.$watch('counter', show); + $scope.$watch('shown', show); + $scope.$watch('username', show); + $scope.$watch('token', show); + } + }; + return directiveDefinitionObject; +}); + + +quayApp.directive('robotsManager', function () { + var directiveDefinitionObject = { + priority: 0, + templateUrl: '/static/directives/robots-manager.html', + replace: false, + transclude: false, + restrict: 'C', + scope: { + 'organization': '=organization', + 'user': '=user' + }, + controller: function($scope, $element, Restangular) { + $scope.robots = null; + $scope.loading = false; + $scope.shownRobot = null; + $scope.showRobotCounter = 0; + + $scope.showRobot = function(info) { + $scope.shownRobot = info; + $scope.showRobotCounter++; + }; + + $scope.getShortenedName = function(name) { + var plus = name.indexOf('+'); + return name.substr(plus + 1); + }; + + $scope.getPrefix = function(name) { + var plus = name.indexOf('+'); + return name.substr(0, plus); + }; + + $scope.createRobot = function(name) { + if (!name) { return; } + + var url = $scope.organization ? getRestUrl('organization', $scope.organization.name, 'robots', name) : + getRestUrl('user/robots', name); + var createRobot = Restangular.one(url); + createRobot.customPUT().then(function(resp) { + $scope.robots.push(resp); + }, function(resp) { + bootbox.dialog({ + "message": resp.data ? resp.data : 'The robot account could not be created', + "title": "Cannot create robot account", + "buttons": { + "close": { + "label": "Close", + "className": "btn-primary" + } + } + }); + }); + }; + + $scope.deleteRobot = function(info) { + var shortName = $scope.getShortenedName(info.name); + var url = $scope.organization ? getRestUrl('organization', $scope.organization.name, 'robots', shortName) : + getRestUrl('user/robots', shortName); + + var deleteRobot = Restangular.one(url); + deleteRobot.customDELETE().then(function(resp) { + for (var i = 0; i < $scope.robots.length; ++i) { + if ($scope.robots[i].name == info.name) { + $scope.robots.splice(i, 1); + return; + } + } + }, function() { + bootbox.dialog({ + "message": 'The selected robot account could not be deleted', + "title": "Cannot delete robot account", + "buttons": { + "close": { + "label": "Close", + "className": "btn-primary" + } + } + }); + }); + }; + + var update = function() { + if (!$scope.user && !$scope.organization) { return; } + if ($scope.loading) { return; } + + $scope.loading = true; + var url = $scope.organization ? getRestUrl('organization', $scope.organization.name, 'robots') : 'user/robots'; + var getRobots = Restangular.one(url); + getRobots.customGET($scope.obj).then(function(resp) { + $scope.robots = resp.robots; + $scope.loading = false; + }); + }; + + $scope.$watch('organization', update); + $scope.$watch('user', update); + } + }; + return directiveDefinitionObject; +}); + + +quayApp.directive('popupInputButton', function () { + var directiveDefinitionObject = { + priority: 0, + templateUrl: '/static/directives/popup-input-button.html', + replace: false, + transclude: true, + restrict: 'C', + scope: { + 'placeholder': '=placeholder', + 'pattern': '=pattern', + 'submitted': '&submitted' + }, + controller: function($scope, $element) { + $scope.popupShown = function() { + setTimeout(function() { + var box = $('#input-box'); + box[0].value = ''; + box.focus(); + }, 10); + }; + + $scope.getRegexp = function(pattern) { + if (!pattern) { + pattern = '.*'; + } + return new RegExp(pattern); + }; + + $scope.inputSubmit = function() { + var box = $('#input-box'); + if (box.hasClass('ng-invalid')) { return; } + + var entered = box[0].value; + if (!entered) { + return; + } + + if ($scope.submitted) { + $scope.submitted({'value': entered}); + } + }; + } + }; + return directiveDefinitionObject; +}); + + quayApp.directive('organizationHeader', function () { var directiveDefinitionObject = { priority: 0, @@ -738,9 +940,10 @@ quayApp.directive('entitySearch', function () { transclude: false, restrict: 'C', scope: { - 'organization': '=organization', + 'namespace': '=namespace', 'inputTitle': '=inputTitle', - 'entitySelected': '=entitySelected' + 'entitySelected': '=entitySelected', + 'includeTeams': '=includeTeams' }, controller: function($scope, $element) { if (!$scope.entitySelected) { return; } @@ -748,15 +951,16 @@ quayApp.directive('entitySearch', function () { number++; var input = $element[0].firstChild; - $scope.organization = $scope.organization || ''; + $scope.namespace = $scope.namespace || ''; $(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); + url += '?namespace=' + encodeURIComponent($scope.namespace); + if ($scope.includeTeams) { + url += '&includeTeams=true' } return url; }, @@ -775,14 +979,16 @@ quayApp.directive('entitySearch', function () { }, template: function (datum) { template = '
'; - if (datum.entity.kind == 'user') { + if (datum.entity.kind == 'user' && !datum.entity.is_robot) { template += ''; + } else if (datum.entity.kind == 'user' && datum.entity.is_robot) { + template += ''; } else if (datum.entity.kind == 'team') { template += ''; } template += '' + datum.value + ''; - if (datum.entity.is_org_member !== undefined && !datum.entity.is_org_member) { + if (datum.entity.is_org_member !== undefined && !datum.entity.is_org_member && datum.kind == 'user') { template += '
This user is outside your organization
'; } diff --git a/static/js/controllers.js b/static/js/controllers.js index 9ac49b60c..c3edc3733 100644 --- a/static/js/controllers.js +++ b/static/js/controllers.js @@ -422,30 +422,19 @@ function RepoAdminCtrl($scope, Restangular, $routeParams, $rootScope) { 'html': true }); - $('#copyClipboard').clipboardCopy(); - var namespace = $routeParams.namespace; var name = $routeParams.name; $scope.permissions = {'team': [], 'user': []}; - $scope.isDownloadSupported = function() { - try { return !!new Blob(); } catch(e){} - return false; + $scope.getPrefix = function(name) { + var plus = name.indexOf('+'); + return name.substr(0, plus + 1); }; - $scope.downloadCfg = function(token) { - var auth = $.base64.encode("$token:" + token.code); - config = { - "https://quay.io/v1/": { - "auth": auth, - "email": "" - } - }; - - var file = JSON.stringify(config, null, ' '); - var blob = new Blob([file]); - saveAs(blob, '.dockercfg'); + $scope.getShortenedName = function(name) { + var plus = name.indexOf('+'); + return name.substr(plus + 1); }; $scope.grantRole = function() { @@ -468,7 +457,7 @@ function RepoAdminCtrl($scope, Restangular, $routeParams, $rootScope) { // Need the $scope.apply for both the permission stuff to change and for // the XHR call to be made. $scope.$apply(function() { - $scope.addRole(entity.name, 'read', entity.kind, entity.is_org_member) + $scope.addRole(entity.name, 'read', entity.kind); }); }; @@ -486,15 +475,14 @@ function RepoAdminCtrl($scope, Restangular, $routeParams, $rootScope) { }); }; - $scope.addRole = function(entityName, role, kind, is_org_member) { + $scope.addRole = function(entityName, role, kind) { var permission = { 'role': role, - 'is_org_member': is_org_member }; var permissionPost = Restangular.one(getRestUrl('repository', namespace, name, 'permissions', kind, entityName)); - permissionPost.customPOST(permission).then(function() { - $scope.permissions[kind][entityName] = permission; + permissionPost.customPOST(permission).then(function(result) { + $scope.permissions[kind][entityName] = result; }, function(result) { $('#cannotchangeModal').modal({}); }); @@ -555,9 +543,11 @@ function RepoAdminCtrl($scope, Restangular, $routeParams, $rootScope) { }); }; + $scope.shownTokenCounter = 0; + $scope.showToken = function(tokenCode) { $scope.shownToken = $scope.tokens[tokenCode]; - $('#tokenmodal').modal({}); + $scope.shownTokenCounter++; }; $scope.askChangeAccess = function(newAccess) { @@ -1104,17 +1094,7 @@ function OrgViewCtrl($rootScope, $scope, Restangular, $routeParams) { }); }; - $scope.createTeamShown = function() { - setTimeout(function() { - $('#create-team-box').focus(); - }, 10); - }; - - $scope.createTeam = function() { - var box = $('#create-team-box'); - if (box.hasClass('ng-invalid')) { return; } - - var teamname = box[0].value.toLowerCase(); + $scope.createTeam = function(teamname) { if (!teamname) { return; } @@ -1247,7 +1227,7 @@ function TeamViewCtrl($rootScope, $scope, Restangular, $routeParams) { 'html': true }); - var orgname = $routeParams.orgname; + $scope.orgname = $routeParams.orgname; var teamname = $routeParams.teamname; $rootScope.title = 'Loading...'; @@ -1258,7 +1238,7 @@ function TeamViewCtrl($rootScope, $scope, Restangular, $routeParams) { if ($scope.members[member.name]) { return; } $scope.$apply(function() { - var addMember = Restangular.one(getRestUrl('organization', orgname, 'team', teamname, 'members', member.name)); + var addMember = Restangular.one(getRestUrl('organization', $scope.orgname, 'team', teamname, 'members', member.name)); addMember.customPOST().then(function(resp) { $scope.members[member.name] = resp; }, function() { @@ -1268,7 +1248,7 @@ function TeamViewCtrl($rootScope, $scope, Restangular, $routeParams) { }; $scope.removeMember = function(username) { - var removeMember = Restangular.one(getRestUrl('organization', orgname, 'team', teamname, 'members', username)); + var removeMember = Restangular.one(getRestUrl('organization', $scope.orgname, 'team', teamname, 'members', username)); removeMember.customDELETE().then(function(resp) { delete $scope.members[username]; }, function() { @@ -1279,7 +1259,7 @@ function TeamViewCtrl($rootScope, $scope, Restangular, $routeParams) { $scope.updateForDescription = function(content) { $scope.organization.teams[teamname].description = content; - var updateTeam = Restangular.one(getRestUrl('organization', orgname, 'team', teamname)); + var updateTeam = Restangular.one(getRestUrl('organization', $scope.orgname, 'team', teamname)); var data = $scope.organization.teams[teamname]; updateTeam.customPUT(data).then(function(resp) { }, function() { @@ -1288,7 +1268,7 @@ function TeamViewCtrl($rootScope, $scope, Restangular, $routeParams) { }; var loadOrganization = function() { - var getOrganization = Restangular.one(getRestUrl('organization', orgname)) + var getOrganization = Restangular.one(getRestUrl('organization', $scope.orgname)) getOrganization.get().then(function(resp) { $scope.organization = resp; $scope.team = $scope.organization.teams[teamname]; @@ -1301,12 +1281,12 @@ function TeamViewCtrl($rootScope, $scope, Restangular, $routeParams) { }; var loadMembers = function() { - var getMembers = Restangular.one(getRestUrl('organization', orgname, 'team', teamname, 'members')); + var getMembers = Restangular.one(getRestUrl('organization', $scope.orgname, 'team', teamname, 'members')); getMembers.get().then(function(resp) { $scope.members = resp.members; $scope.canEditMembers = resp.can_edit; $scope.loading = !$scope.organization || !$scope.members; - $rootScope.title = teamname + ' (' + orgname + ')'; + $rootScope.title = teamname + ' (' + $scope.orgname + ')'; $rootScope.description = 'Team management page for team ' + teamname + ' under organization ' + orgname; }, function() { $scope.organization = null; diff --git a/static/partials/create-team-dialog.html b/static/partials/create-team-dialog.html deleted file mode 100644 index 41a599189..000000000 --- a/static/partials/create-team-dialog.html +++ /dev/null @@ -1,3 +0,0 @@ -
- -
diff --git a/static/partials/guide.html b/static/partials/guide.html index e3af803a6..49124aa1d 100644 --- a/static/partials/guide.html +++ b/static/partials/guide.html @@ -50,11 +50,32 @@ Email: my@email.com

+

Using robot accounts Requires Admin Access

+
+
+ There are many circumstances where permissions for repositories need to be shared across those repositories (continuous integration, etc). + To support this case, Quay allows the use of robot accounts which can be created in the user/organization's admin view and can be + shared by multiple repositories that are owned by that user or organization. +
+ +
    +
  • Robot accounts can be managed in the user or organization admin's interface +
  • Adding a robot account: Click "Create Robot Account" and enter a name for the account. The username will become namespace+accountname where "namespace" is the name of the user or organiaztion. +
  • Setting permissions: Permissions can be granted to a robot account in a repository by adding that account like any other user or team. +
  • Deleting a robot account: A robot account can be deleted by clicking the X and then clicking Delete +
  • Using a robot account: To use the robot account, the following credentials can be used: +
    +
    Username
    namespace+accountname (Example: mycompany+deploy)
    +
    Password
    (token value can be found by clicking on the robot account in the admin panel)
    +
    Email
    This value is ignored, any value may be used.
    +
    +
+
+

Using access tokens in place of users Requires Admin Access

- There are many circumstances where it makes sense to not use a user's username and password (deployment scripts, etc). - To support this case, Quay allows the use of access tokens which can be created on a repository and have read and/or write + For per-repository token authentication, Quay allows the use of access tokens which can be created on a repository and have read and/or write permissions, without any passwords.
diff --git a/static/partials/org-admin.html b/static/partials/org-admin.html index d31d10279..6b110099e 100644 --- a/static/partials/org-admin.html +++ b/static/partials/org-admin.html @@ -15,6 +15,7 @@ @@ -28,6 +29,11 @@
+ +
+
+
+
diff --git a/static/partials/org-view.html b/static/partials/org-view.html index c1011d74f..2d05e1426 100644 --- a/static/partials/org-view.html +++ b/static/partials/org-view.html @@ -9,7 +9,12 @@
- + + + Create Team + + Settings
diff --git a/static/partials/repo-admin.html b/static/partials/repo-admin.html index c44504a37..df7ff652c 100644 --- a/static/partials/repo-admin.html +++ b/static/partials/repo-admin.html @@ -34,16 +34,16 @@
-
User and Team Access Permissions +
Access Permissions - +
- + @@ -52,7 +52,7 @@ - -
User/TeamUser/Team/Robot Account Permissions
- + {{name}} @@ -68,9 +68,10 @@
- - {{name}} + + + + {{getPrefix(name)}}{{getShortenedName(name)}} @@ -87,8 +88,8 @@
- +
@@ -106,7 +107,7 @@ - + @@ -132,10 +133,10 @@ - - @@ -237,6 +238,10 @@ +
+ {{ shownToken.friendlyName }} +
- - - -
Token DescriptionToken Description Permissions
+ +
- + + {{ member.username }} @@ -33,7 +34,7 @@
- +
diff --git a/static/partials/user-admin.html b/static/partials/user-admin.html index e6c7123e6..bdf222523 100644 --- a/static/partials/user-admin.html +++ b/static/partials/user-admin.html @@ -28,6 +28,7 @@ @@ -59,6 +60,11 @@
+ +
+
+
+
diff --git a/test/data/test.db b/test/data/test.db index 6a2cb0c86..fdb9329a0 100644 Binary files a/test/data/test.db and b/test/data/test.db differ diff --git a/util/names.py b/util/names.py index 25616a346..7e48468bb 100644 --- a/util/names.py +++ b/util/names.py @@ -20,3 +20,7 @@ def parse_repository_name(f): (namespace, repository) = parse_namespace_repository(repository) return f(namespace, repository, *args, **kwargs) return wrapper + + +def format_robot_username(parent_username, robot_shortname): + return '%s+%s' % (parent_username, robot_shortname)