diff --git a/auth/permissions.py b/auth/permissions.py index 59af7be42..a55fc3115 100644 --- a/auth/permissions.py +++ b/auth/permissions.py @@ -22,6 +22,7 @@ _TeamTypeNeed = namedtuple('teamwideneed', ['type', 'orgname', 'teamname', 'role _TeamNeed = partial(_TeamTypeNeed, 'orgteam') _UserTypeNeed = namedtuple('userspecificneed', ['type', 'username', 'role']) _UserNeed = partial(_UserTypeNeed, 'user') +_SuperUserNeed = partial(namedtuple('superuserneed', ['type']), '_superuser') REPO_ROLES = [None, 'read', 'write', 'admin'] @@ -88,6 +89,10 @@ class QuayDeferredPermissionUser(Identity): logger.debug('Loading user permissions after deferring.') user_object = model.get_user(self.id) + # Add the superuser need, if applicable. + if user_object.username is not None and user_object.username in app.config.get('SUPER_USERS', []): + self.provides.add(_SuperUserNeed()) + # Add the user specific permissions, only for non-oauth permission user_grant = _UserNeed(user_object.username, self._user_role_for_scopes('admin')) logger.debug('User permission: {0}'.format(user_grant)) @@ -171,6 +176,11 @@ class CreateRepositoryPermission(Permission): super(CreateRepositoryPermission, self).__init__(admin_org, create_repo_org) +class SuperUserPermission(Permission): + def __init__(self): + need = _SuperUserNeed() + super(SuperUserPermission, self).__init__(need) + class UserAdminPermission(Permission): def __init__(self, username): diff --git a/config.py b/config.py index 4cb3a5d6b..4d76442e8 100644 --- a/config.py +++ b/config.py @@ -126,6 +126,9 @@ class DefaultConfig(object): STATUS_TAGS[tag_name] = tag_svg.read() + # Super user config. Note: This MUST BE None for the default config. + SUPER_USERS = None + # Feature Flag: Whether billing is required. FEATURE_BILLING = True @@ -136,4 +139,7 @@ class DefaultConfig(object): FEATURE_GITHUB_LOGIN = True # Feature flag, whether to enable olark chat - FEATURE_OLARK_CHAT = True \ No newline at end of file + FEATURE_OLARK_CHAT = True + + # Feature Flag: Whether super users are supported. + FEATURE_SUPER_USERS = False diff --git a/data/model/legacy.py b/data/model/legacy.py index 8bb65157f..f6a07a997 100644 --- a/data/model/legacy.py +++ b/data/model/legacy.py @@ -1517,8 +1517,7 @@ def delete_webhook(namespace_name, repository_name, public_id): return webhook -def list_logs(user_or_organization_name, start_time, end_time, performer=None, - repository=None): +def list_logs(start_time, end_time, performer=None, repository=None, namespace=None): joined = LogEntry.select().join(User) if repository: joined = joined.where(LogEntry.repository == repository) @@ -1526,8 +1525,10 @@ def list_logs(user_or_organization_name, start_time, end_time, performer=None, if performer: joined = joined.where(LogEntry.performer == performer) + if namespace: + joined = joined.where(User.username == namespace_name) + return joined.where( - User.username == user_or_organization_name, LogEntry.datetime >= start_time, LogEntry.datetime < end_time).order_by(LogEntry.datetime.desc()) @@ -1629,3 +1630,15 @@ def delete_notifications_by_kind(target, kind): kind_ref = NotificationKind.get(name=kind) Notification.delete().where(Notification.target == target, Notification.kind == kind_ref).execute() + + +def get_active_users(): + return User.select().where(User.organization == False, User.robot == False) + +def get_active_user_count(): + return get_active_users().count() + +def delete_user(user): + user.delete_instance(recursive=True, delete_nullable=True) + + # TODO: also delete any repository data associated diff --git a/endpoints/api/__init__.py b/endpoints/api/__init__.py index 97f766da7..3abefa54f 100644 --- a/endpoints/api/__init__.py +++ b/endpoints/api/__init__.py @@ -302,6 +302,7 @@ import endpoints.api.repository import endpoints.api.repotoken import endpoints.api.robot import endpoints.api.search +import endpoints.api.superuser import endpoints.api.tag import endpoints.api.team import endpoints.api.trigger diff --git a/endpoints/api/logs.py b/endpoints/api/logs.py index abd2c3e03..2ce2bbb30 100644 --- a/endpoints/api/logs.py +++ b/endpoints/api/logs.py @@ -29,8 +29,7 @@ def log_view(log): return view -def get_logs(namespace, start_time, end_time, performer_name=None, - repository=None): +def get_logs(start_time, end_time, performer_name=None, repository=None, namespace=None): performer = None if performer_name: performer = model.get_user(performer_name) @@ -54,8 +53,8 @@ def get_logs(namespace, start_time, end_time, performer_name=None, if not end_time: end_time = datetime.today() - logs = model.list_logs(namespace, start_time, end_time, performer=performer, - repository=repository) + logs = model.list_logs(start_time, end_time, performer=performer, repository=repository, + namespace=namespace) return { 'start_time': format_date(start_time), 'end_time': format_date(end_time), @@ -80,7 +79,7 @@ class RepositoryLogs(RepositoryParamResource): start_time = args['starttime'] end_time = args['endtime'] - return get_logs(namespace, start_time, end_time, repository=repo) + return get_logs(start_time, end_time, repository=repo, namespace=namespace) @resource('/v1/user/logs') @@ -100,7 +99,7 @@ class UserLogs(ApiResource): end_time = args['endtime'] user = get_authenticated_user() - return get_logs(user.username, start_time, end_time, performer_name=performer_name) + return get_logs(start_time, end_time, performer_name=performer_name, namespace=user.username) @resource('/v1/organization//logs') @@ -121,6 +120,6 @@ class OrgLogs(ApiResource): start_time = args['starttime'] end_time = args['endtime'] - return get_logs(orgname, start_time, end_time, performer_name=performer_name) + return get_logs(start_time, end_time, namespace=orgname, performer_name=performer_name) - raise Unauthorized() \ No newline at end of file + raise Unauthorized() diff --git a/endpoints/api/superuser.py b/endpoints/api/superuser.py new file mode 100644 index 000000000..2688f6945 --- /dev/null +++ b/endpoints/api/superuser.py @@ -0,0 +1,160 @@ +import logging +import json + +from app import app + +from flask import request + +from endpoints.api import (ApiResource, nickname, resource, validate_json_request, request_error, + log_action, internal_only, NotFound, require_user_admin, format_date, + InvalidToken, require_scope, format_date, hide_if, show_if, parse_args, + query_param, abort) + +from endpoints.api.logs import get_logs + +from data import model +from auth.permissions import SuperUserPermission +from auth.auth_context import get_authenticated_user + +import features + +logger = logging.getLogger(__name__) + +@resource('/v1/superuser/logs') +@internal_only +@show_if(features.SUPER_USERS) +class SuperUserLogs(ApiResource): + """ Resource for fetching all logs in the system. """ + @nickname('listAllLogs') + @parse_args + @query_param('starttime', 'Earliest time from which to get logs. (%m/%d/%Y %Z)', type=str) + @query_param('endtime', 'Latest time to which to get logs. (%m/%d/%Y %Z)', type=str) + @query_param('performer', 'Username for which to filter logs.', type=str) + def get(self, args): + """ List the logs for the current system. """ + if SuperUserPermission().can(): + performer_name = args['performer'] + start_time = args['starttime'] + end_time = args['endtime'] + + return get_logs(start_time, end_time) + + abort(403) + + +@resource('/v1/superuser/seats') +@internal_only +@show_if(features.SUPER_USERS) +@hide_if(features.BILLING) +class SeatUsage(ApiResource): + """ Resource for managing the seats granted in the license for the system. """ + @nickname('getSeatCount') + def get(self): + """ Returns the current number of seats being used in the system. """ + if SuperUserPermission().can(): + return { + 'count': model.get_active_user_count(), + 'allowed': app.config.get('LICENSE_SEAT_COUNT', 0) + } + + abort(403) + + +def user_view(user): + return { + 'username': user.username, + 'email': user.email, + 'verified': user.verified, + 'super_user': user.username in app.config['SUPER_USERS'] + } + +@resource('/v1/superuser/users/') +@internal_only +@show_if(features.SUPER_USERS) +class SuperUserList(ApiResource): + """ Resource for listing users in the system. """ + @nickname('listAllUsers') + def get(self): + """ Returns a list of all users in the system. """ + if SuperUserPermission().can(): + users = model.get_active_users() + return { + 'users': [user_view(user) for user in users] + } + + abort(403) + + +@resource('/v1/superuser/users/') +@internal_only +@show_if(features.SUPER_USERS) +class SuperUserManagement(ApiResource): + """ Resource for managing users in the system. """ + schemas = { + 'UpdateUser': { + 'id': 'UpdateUser', + 'type': 'object', + 'description': 'Description of updates for a user', + 'properties': { + 'password': { + 'type': 'string', + 'description': 'The new password for the user', + }, + 'email': { + 'type': 'string', + 'description': 'The new e-mail address for the user', + } + }, + }, + } + + @nickname('getInstallUser') + def get(self, username): + """ Returns information about the specified user. """ + if SuperUserPermission().can(): + user = model.get_user(username) + if not user or user.organization or user.robot: + abort(404) + + return user_view(user) + + abort(403) + + @nickname('deleteInstallUser') + def delete(self, username): + """ Deletes the specified user. """ + if SuperUserPermission().can(): + user = model.get_user(username) + if not user or user.organization or user.robot: + abort(404) + + if username in app.config['SUPER_USERS']: + abort(403) + + model.delete_user(user) + return 'Deleted', 204 + + abort(403) + + @nickname('changeInstallUser') + @validate_json_request('UpdateUser') + def put(self, username): + """ Updates information about the specified user. """ + if SuperUserPermission().can(): + user = model.get_user(username) + if not user or user.organization or user.robot: + abort(404) + + if username in app.config['SUPER_USERS']: + abort(403) + + user_data = request.get_json() + if 'password' in user_data: + model.change_password(user, user_data['password']) + + if 'email' in user_data: + model.update_email(user, user_data['email']) + + return user_view(user) + + abort(403) diff --git a/endpoints/api/user.py b/endpoints/api/user.py index 40194a436..c40dcc649 100644 --- a/endpoints/api/user.py +++ b/endpoints/api/user.py @@ -15,7 +15,7 @@ from endpoints.common import common_login from data import model from data.plans import get_plan from auth.permissions import (AdministerOrganizationPermission, CreateRepositoryPermission, - UserAdminPermission, UserReadPermission) + UserAdminPermission, UserReadPermission, SuperUserPermission) from auth.auth_context import get_authenticated_user from auth import scopes from util.gravatar import compute_hash @@ -66,6 +66,11 @@ def user_view(user): 'preferred_namespace': not (user.stripe_id is None), }) + if features.SUPER_USERS: + user_response.update({ + 'super_user': user and user == get_authenticated_user() and SuperUserPermission().can() + }) + return user_response diff --git a/endpoints/web.py b/endpoints/web.py index e14c70e79..869881718 100644 --- a/endpoints/web.py +++ b/endpoints/web.py @@ -85,6 +85,12 @@ def organizations(): def user(): return index('') +@web.route('/superuser/') +@no_cache +@route_show_if(features.SUPER_USERS) +def superuser(): + return index('') + @web.route('/signin/') @no_cache diff --git a/static/css/quay.css b/static/css/quay.css index 919c0ddc9..b00e09492 100644 --- a/static/css/quay.css +++ b/static/css/quay.css @@ -2316,11 +2316,6 @@ p.editable:hover i { margin-bottom: 10px; } -.user-admin .form-change input { - margin-top: 12px; - margin-bottom: 12px; -} - .user-admin .convert-form h3 { margin-bottom: 20px; } @@ -2457,42 +2452,42 @@ p.editable:hover i { stroke-width: 1.5px; } -#repository-usage-chart { +.usage-chart { display: inline-block; vertical-align: middle; width: 200px; height: 200px; } -#repository-usage-chart .count-text { +.usage-chart .count-text { font-size: 22px; } -#repository-usage-chart.limit-at path.arc-0 { +.usage-chart.limit-at path.arc-0 { fill: #c09853; } -#repository-usage-chart.limit-over path.arc-0 { +.usage-chart.limit-over path.arc-0 { fill: #b94a48; } -#repository-usage-chart.limit-near path.arc-0 { +.usage-chart.limit-near path.arc-0 { fill: #468847; } -#repository-usage-chart.limit-over path.arc-1 { +.usage-chart.limit-over path.arc-1 { fill: #fcf8e3; } -#repository-usage-chart.limit-at path.arc-1 { +.usage-chart.limit-at path.arc-1 { fill: #f2dede; } -#repository-usage-chart.limit-near path.arc-1 { +.usage-chart.limit-near path.arc-1 { fill: #dff0d8; } -.plan-manager-element .usage-caption { +.usage-caption { display: inline-block; color: #aaa; font-size: 26px; @@ -3623,4 +3618,17 @@ pre.command:before { .trigger-option-section table td { padding: 6px; +} + +.user-row.super-user td { + background-color: #d9edf7; +} + +.user-row .user-class { + text-transform: uppercase; +} + +.form-change input { + margin-top: 12px; + margin-bottom: 12px; } \ No newline at end of file diff --git a/static/directives/header-bar.html b/static/directives/header-bar.html index 05f7e24cf..9ee91a463 100644 --- a/static/directives/header-bar.html +++ b/static/directives/header-bar.html @@ -65,6 +65,7 @@
  • Organizations
  • +
  • Super User Admin Panel
  • Sign out
  • diff --git a/static/directives/plan-manager.html b/static/directives/plan-manager.html index 6d393fb21..f2ad26df4 100644 --- a/static/directives/plan-manager.html +++ b/static/directives/plan-manager.html @@ -20,7 +20,7 @@
    -
    +
    Repository Usage
    diff --git a/static/js/app.js b/static/js/app.js index 59d80e715..a7adc12dd 100644 --- a/static/js/app.js +++ b/static/js/app.js @@ -1283,6 +1283,8 @@ quayApp = angular.module('quay', quayDependencies, function($provide, cfpLoading templateUrl: '/static/partials/repo-list.html', controller: RepoListCtrl}). when('/user/', {title: 'Account Settings', description:'Account settings for Quay.io', templateUrl: '/static/partials/user-admin.html', reloadOnSearch: false, controller: UserAdminCtrl}). + when('/superuser/', {title: 'Superuser Admin Panel', description:'Admin panel for Quay.io', templateUrl: '/static/partials/super-user.html', + reloadOnSearch: false, controller: SuperUserAdminCtrl}). when('/guide/', {title: 'Guide', description:'Guide to using private docker repositories on Quay.io', templateUrl: '/static/partials/guide.html', controller: GuideCtrl}). when('/tutorial/', {title: 'Tutorial', description:'Interactive tutorial for using Quay.io', templateUrl: '/static/partials/tutorial.html', @@ -3333,7 +3335,7 @@ quayApp.directive('planManager', function () { } if (!$scope.chart) { - $scope.chart = new RepositoryUsageChart(); + $scope.chart = new UsageChart(); $scope.chart.draw('repository-usage-chart'); } diff --git a/static/js/controllers.js b/static/js/controllers.js index fee9029ea..d917a4009 100644 --- a/static/js/controllers.js +++ b/static/js/controllers.js @@ -2638,4 +2638,135 @@ function ManageApplicationCtrl($scope, $routeParams, $rootScope, $location, $tim // Load the organization and application info. loadOrganization(); loadApplicationInfo(); +} + + +function SuperUserAdminCtrl($scope, ApiService, Features, UserService) { + if (!Features.SUPER_USERS) { + return; + } + + // Monitor any user changes and place the current user into the scope. + UserService.updateUserIn($scope); + + $scope.loadUsers = function() { + if ($scope.users) { + return; + } + + $scope.loadUsersInternal(); + }; + + $scope.loadUsersInternal = function() { + ApiService.listAllUsers().then(function(resp) { + $scope.users = resp['users']; + }, function(resp) { + $scope.users = []; + $scope.usersError = resp['data']['message'] || resp['data']['error_description']; + }); + }; + + $scope.showChangePassword = function(user) { + $scope.userToChange = user; + $('#changePasswordModal').modal({}); + }; + + $scope.showDeleteUser = function(user) { + if (user.username == UserService.currentUser().username) { + bootbox.dialog({ + "message": 'Cannot delete yourself!', + "title": "Cannot delete user", + "buttons": { + "close": { + "label": "Close", + "className": "btn-primary" + } + } + }); + return; + } + + $scope.userToDelete = user; + $('#confirmDeleteUserModal').modal({}); + }; + + $scope.changeUserPassword = function(user) { + $('#changePasswordModal').modal('hide'); + + var params = { + 'username': user.username + }; + + var data = { + 'password': user.password + }; + + ApiService.changeInstallUser(data, params).then(function(resp) { + $scope.loadUsersInternal(); + }, function(resp) { + bootbox.dialog({ + "message": resp.data ? resp.data.message : 'Could not change user', + "title": "Cannot change user", + "buttons": { + "close": { + "label": "Close", + "className": "btn-primary" + } + } + }); + }); + }; + + $scope.deleteUser = function(user) { + $('#confirmDeleteUserModal').modal('hide'); + + var params = { + 'username': user.username + }; + + ApiService.deleteInstallUser(null, params).then(function(resp) { + $scope.loadUsersInternal(); + }, function(resp) { + bootbox.dialog({ + "message": resp.data ? resp.data.message : 'Could not delete user', + "title": "Cannot delete user", + "buttons": { + "close": { + "label": "Close", + "className": "btn-primary" + } + } + }); + }); + }; + + var seatUsageLoaded = function(usage) { + $scope.usageLoading = false; + + if (usage.count > usage.allowed) { + $scope.limit = 'over'; + } else if (usage.count == usage.allowed) { + $scope.limit = 'at'; + } else if (usage.count >= usage.allowed * 0.7) { + $scope.limit = 'near'; + } else { + $scope.limit = 'none'; + } + + if (!$scope.chart) { + $scope.chart = new UsageChart(); + $scope.chart.draw('seat-usage-chart'); + } + + $scope.chart.update(usage.count, usage.allowed); + }; + + var loadSeatUsage = function() { + $scope.usageLoading = true; + ApiService.getSeatCount().then(function(resp) { + seatUsageLoaded(resp); + }); + }; + + loadSeatUsage(); } \ No newline at end of file diff --git a/static/js/graphing.js b/static/js/graphing.js index d8bb2e5ff..e68ee36e1 100644 --- a/static/js/graphing.js +++ b/static/js/graphing.js @@ -1329,7 +1329,7 @@ FileTree.prototype.getNodesHeight = function() { /** * Based off of http://bl.ocks.org/mbostock/1346410 */ -function RepositoryUsageChart() { +function UsageChart() { this.total_ = null; this.count_ = null; this.drawn_ = false; @@ -1339,7 +1339,7 @@ function RepositoryUsageChart() { /** * Updates the chart with the given count and total of number of repositories. */ -RepositoryUsageChart.prototype.update = function(count, total) { +UsageChart.prototype.update = function(count, total) { if (!this.g_) { return; } this.total_ = total; this.count_ = count; @@ -1350,7 +1350,7 @@ RepositoryUsageChart.prototype.update = function(count, total) { /** * Conducts the actual draw or update (if applicable). */ -RepositoryUsageChart.prototype.drawInternal_ = function() { +UsageChart.prototype.drawInternal_ = function() { // If the total is null, then we have not yet set the proper counts. if (this.total_ === null) { return; } @@ -1409,7 +1409,7 @@ RepositoryUsageChart.prototype.drawInternal_ = function() { /** * Draws the chart in the given container. */ -RepositoryUsageChart.prototype.draw = function(container) { +UsageChart.prototype.draw = function(container) { var cw = 200; var ch = 200; var radius = Math.min(cw, ch) / 2; diff --git a/static/partials/super-user.html b/static/partials/super-user.html new file mode 100644 index 000000000..b7d60a8a8 --- /dev/null +++ b/static/partials/super-user.html @@ -0,0 +1,149 @@ +
    +
    + This panel provides administrator access to super users of this installation of Quay.io. Super users can be managed in the configuration for this installation. +
    + +
    + +
    + +
    + + +
    +
    + +
    +
    + +
    +
    + Seat Usage +
    + +
    + + +
    +
    +
    + {{ usersError }} +
    +
    +
    +
    + Showing {{(users | filter:search | limitTo:100).length}} of + {{(users | filter:search).length}} matching users +
    +
    + +
    +
    + + + + + + + + + + + + + + + +
    UsernameE-mail address
    + + {{ current_user.username }} + + {{ current_user.email }} + + Super user + + +
    + +
    +
    +
    +
    +
    + + + + + + + + + +