Add support for deleting namespaces (users, organizations)

Fixes #102
Fixes #105
This commit is contained in:
Joseph Schorr 2016-08-09 17:58:33 -04:00
parent a74e94fb67
commit 73eb66eac5
23 changed files with 407 additions and 33 deletions

14
app.py
View file

@ -208,11 +208,17 @@ dex_login = DexOAuthConfig(app.config, 'DEX_LOGIN_CONFIG')
oauth_apps = [github_login, github_trigger, gitlab_trigger, google_login, dex_login] oauth_apps = [github_login, github_trigger, gitlab_trigger, google_login, dex_login]
image_replication_queue = WorkQueue(app.config['REPLICATION_QUEUE_NAME'], tf) image_replication_queue = WorkQueue(app.config['REPLICATION_QUEUE_NAME'], tf, has_namespace=False)
dockerfile_build_queue = WorkQueue(app.config['DOCKERFILE_BUILD_QUEUE_NAME'], tf, dockerfile_build_queue = WorkQueue(app.config['DOCKERFILE_BUILD_QUEUE_NAME'], tf,
reporter=BuildMetricQueueReporter(metric_queue)) reporter=BuildMetricQueueReporter(metric_queue),
notification_queue = WorkQueue(app.config['NOTIFICATION_QUEUE_NAME'], tf) has_namespace=True)
secscan_notification_queue = WorkQueue(app.config['SECSCAN_NOTIFICATION_QUEUE_NAME'], tf) notification_queue = WorkQueue(app.config['NOTIFICATION_QUEUE_NAME'], tf, has_namespace=True)
secscan_notification_queue = WorkQueue(app.config['SECSCAN_NOTIFICATION_QUEUE_NAME'], tf,
has_namespace=False)
all_queues = [image_replication_queue, dockerfile_build_queue, notification_queue,
secscan_notification_queue]
secscan_api = SecurityScannerAPI(app, app.config, storage) secscan_api = SecurityScannerAPI(app, app.config, storage)
# Check for a key in config. If none found, generate a new signing key for Docker V2 manifests. # Check for a key in config. If none found, generate a new signing key for Docker V2 manifests.

View file

@ -16,6 +16,7 @@ from buildman.jobutil.workererror import WorkerError
from data import model from data import model
from data.database import BUILD_PHASE from data.database import BUILD_PHASE
from data.model import InvalidRepositoryBuildException
HEARTBEAT_DELTA = datetime.timedelta(seconds=30) HEARTBEAT_DELTA = datetime.timedelta(seconds=30)
BUILD_HEARTBEAT_DELAY = datetime.timedelta(seconds=30) BUILD_HEARTBEAT_DELAY = datetime.timedelta(seconds=30)
@ -241,8 +242,13 @@ class BuildComponent(BaseComponent):
# Parse and update the phase and the status_dict. The status dictionary contains # Parse and update the phase and the status_dict. The status dictionary contains
# the pull/push progress, as well as the current step index. # the pull/push progress, as well as the current step index.
with self._build_status as status_dict: with self._build_status as status_dict:
try:
if self._build_status.set_phase(phase, log_data.get('status_data')): if self._build_status.set_phase(phase, log_data.get('status_data')):
logger.debug('Build %s has entered a new phase: %s', self.builder_realm, phase) logger.debug('Build %s has entered a new phase: %s', self.builder_realm, phase)
except InvalidRepositoryBuildException:
build_id = self._current_job.repo_build.uuid
logger.info('Build %s was not found; repo was probably deleted', build_id)
return
BuildComponent._process_pushpull_status(status_dict, phase, log_data, self._image_info) BuildComponent._process_pushpull_status(status_dict, phase, log_data, self._image_info)
@ -300,7 +306,12 @@ class BuildComponent(BaseComponent):
except: except:
pass pass
try:
self._build_status.set_phase(BUILD_PHASE.COMPLETE) self._build_status.set_phase(BUILD_PHASE.COMPLETE)
except InvalidRepositoryBuildException:
logger.info('Build %s was not found; repo was probably deleted', build_id)
return
trollius.async(self._build_finished(BuildJobResult.COMPLETE)) trollius.async(self._build_finished(BuildJobResult.COMPLETE))
# Label the pushed manifests with the build metadata. # Label the pushed manifests with the build metadata.

View file

@ -359,8 +359,15 @@ class User(BaseModel):
raise RuntimeError('Non-recursive delete on user.') raise RuntimeError('Non-recursive delete on user.')
# These models don't need to use transitive deletes, because the referenced objects # These models don't need to use transitive deletes, because the referenced objects
# are cleaned up directly # are cleaned up directly in the model.
skip_transitive_deletes = {Image} skip_transitive_deletes = {Image, Repository, Team, RepositoryBuild, ServiceKeyApproval,
RepositoryBuildTrigger, ServiceKey, RepositoryPermission,
TeamMemberInvite, Star, RepositoryAuthorizedEmail, TeamMember,
RepositoryTag, PermissionPrototype, DerivedStorageForImage,
TagManifest, AccessToken, OAuthAccessToken, BlobUpload,
RepositoryNotification, OAuthAuthorizationCode,
RepositoryActionCount, TagManifestLabel}
delete_instance_filtered(self, User, delete_nullable, skip_transitive_deletes) delete_instance_filtered(self, User, delete_nullable, skip_transitive_deletes)

View file

@ -9,7 +9,8 @@ from datetime import datetime, timedelta
from data.database import (User, LoginService, FederatedLogin, RepositoryPermission, TeamMember, from data.database import (User, LoginService, FederatedLogin, RepositoryPermission, TeamMember,
Team, Repository, TupleSelector, TeamRole, Namespace, Visibility, Team, Repository, TupleSelector, TeamRole, Namespace, Visibility,
EmailConfirmation, Role, db_for_update, random_string_generator, EmailConfirmation, Role, db_for_update, random_string_generator,
UserRegion, ImageStorageLocation) UserRegion, ImageStorageLocation, QueueItem, TeamMemberInvite,
ServiceKeyApproval, OAuthApplication, RepositoryBuildTrigger)
from data.model import (DataModelException, InvalidPasswordException, InvalidRobotException, from data.model import (DataModelException, InvalidPasswordException, InvalidRobotException,
InvalidUsernameException, InvalidEmailAddressException, InvalidUsernameException, InvalidEmailAddressException,
TooManyUsersException, TooManyLoginAttemptsException, db_transaction, TooManyUsersException, TooManyLoginAttemptsException, db_transaction,
@ -657,11 +658,81 @@ def detach_external_login(user, service_name):
FederatedLogin.service == service).execute() FederatedLogin.service == service).execute()
def delete_user(user): def get_solely_admined_organizations(user_obj):
""" Returns the organizations admined solely by the given user. """
orgs = (User.select()
.where(User.organization == True)
.join(Team)
.join(TeamRole)
.where(TeamRole.name == 'admin')
.switch(Team)
.join(TeamMember)
.where(TeamMember.user == user_obj)
.distinct())
# Filter to organizations where the user is the sole admin.
solely_admined = []
for org in orgs:
admin_user_count = (TeamMember.select()
.join(Team)
.join(TeamRole)
.where(Team.organization == org, TeamRole.name == 'admin')
.switch(TeamMember)
.join(User)
.where(User.robot == False)
.distinct()
.count())
if admin_user_count == 1:
solely_admined.append(org)
return solely_admined
def delete_user(user, queues, force=False):
if not force and not user.organization:
# Ensure that the user is not the sole admin for any organizations. If so, then the user
# cannot be deleted before those organizations are deleted or reassigned.
organizations = get_solely_admined_organizations(user)
if len(organizations) > 0:
message = 'Cannot delete %s as you are the only admin for organizations: ' % user.username
for index, org in enumerate(organizations):
if index > 0:
message = message + ', '
message = message + org.username
raise DataModelException(message)
# Delete all queue items for the user.
for queue in queues:
queue.delete_namespaced_items(user.username)
# Delete any repositories under the user's namespace. # Delete any repositories under the user's namespace.
for repo in list(Repository.select().where(Repository.namespace_user == user)): for repo in list(Repository.select().where(Repository.namespace_user == user)):
repository.purge_repository(user.username, repo.name) repository.purge_repository(user.username, repo.name)
if user.organization:
# Delete the organization's teams.
for team in Team.select().where(Team.organization == user):
team.delete_instance(recursive=True)
# Delete any OAuth approvals and tokens associated with the user.
for app in OAuthApplication.select().where(OAuthApplication.organization == user):
app.delete_instance(recursive=True)
else:
# Remove the user from any teams in which they are a member.
TeamMember.delete().where(TeamMember.user == user).execute()
# Delete any repository buildtriggers where the user is the connected user.
triggers = RepositoryBuildTrigger.select().where(RepositoryBuildTrigger.connected_user == user)
for trigger in triggers:
trigger.delete_instance(recursive=True, delete_nullable=False)
# Null out any service key approvals. We technically lose information here, but its better than
# falling and only occurs if a superuser is being deleted.
ServiceKeyApproval.update(approver=None).where(ServiceKeyApproval.approver == user).execute()
# Delete the user itself. # Delete the user itself.
user.delete_instance(recursive=True, delete_nullable=True) user.delete_instance(recursive=True, delete_nullable=True)

View file

@ -33,12 +33,14 @@ class BuildMetricQueueReporter(object):
class WorkQueue(object): class WorkQueue(object):
""" Work queue defines methods for interacting with a queue backed by the database. """ """ Work queue defines methods for interacting with a queue backed by the database. """
def __init__(self, queue_name, transaction_factory, def __init__(self, queue_name, transaction_factory,
canonical_name_match_list=None, reporter=None, metric_queue=None): canonical_name_match_list=None, reporter=None, metric_queue=None,
has_namespace=False):
self._queue_name = queue_name self._queue_name = queue_name
self._reporter = reporter self._reporter = reporter
self._metric_queue = metric_queue self._metric_queue = metric_queue
self._transaction_factory = transaction_factory self._transaction_factory = transaction_factory
self._currently_processing = False self._currently_processing = False
self._has_namespaced_items = has_namespace
if canonical_name_match_list is None: if canonical_name_match_list is None:
self._canonical_name_match_list = [] self._canonical_name_match_list = []
@ -130,6 +132,15 @@ class WorkQueue(object):
except QueueItem.DoesNotExist: except QueueItem.DoesNotExist:
return False return False
def delete_namespaced_items(self, namespace, subpath=None):
""" Deletes all items in this queue that exist under the given namespace. """
if not self._has_namespaced_items:
return False
subpath_query = '%s/' % subpath if subpath else ''
queue_prefix = '%s/%s/%s%%' % (self._queue_name, namespace, subpath_query)
QueueItem.delete().where(QueueItem.queue_name ** queue_prefix).execute()
def put(self, canonical_name_list, message, available_after=0, retries_remaining=5): def put(self, canonical_name_list, message, available_after=0, retries_remaining=5):
""" """
Put an item, if it shouldn't be processed for some number of seconds, Put an item, if it shouldn't be processed for some number of seconds,

View file

@ -6,10 +6,10 @@ from flask import request
import features import features
from app import billing as stripe, avatar from app import billing as stripe, avatar, all_queues
from endpoints.api import (resource, nickname, ApiResource, validate_json_request, request_error, from endpoints.api import (resource, nickname, ApiResource, validate_json_request, request_error,
related_user_resource, internal_only, require_user_admin, log_action, related_user_resource, internal_only, require_user_admin, log_action,
show_if, path_param, require_scope) show_if, path_param, require_scope, require_fresh_login)
from endpoints.exception import Unauthorized, NotFound from endpoints.exception import Unauthorized, NotFound
from endpoints.api.user import User, PrivateRepositories from endpoints.api.user import User, PrivateRepositories
from auth.permissions import (AdministerOrganizationPermission, OrganizationMemberPermission, from auth.permissions import (AdministerOrganizationPermission, OrganizationMemberPermission,
@ -199,6 +199,23 @@ class Organization(ApiResource):
raise Unauthorized() raise Unauthorized()
@require_scope(scopes.ORG_ADMIN)
@require_fresh_login
@nickname('deleteOrganization')
def delete(self, orgname):
""" Deletes the specified organization. """
permission = AdministerOrganizationPermission(orgname)
if permission.can():
try:
org = model.organization.get_organization(orgname)
except model.InvalidOrganizationException:
raise NotFound()
model.user.delete_user(org, all_queues)
return 'Deleted', 204
@resource('/v1/organization/<orgname>/private') @resource('/v1/organization/<orgname>/private')
@path_param('orgname', 'The name of the organization') @path_param('orgname', 'The name of the organization')
@internal_only @internal_only

View file

@ -8,8 +8,8 @@ from datetime import timedelta, datetime
from flask import request, abort from flask import request, abort
from app import dockerfile_build_queue
from data import model from data import model
from data.database import Repository as RepositoryTable
from endpoints.api import (truthy_bool, format_date, nickname, log_action, validate_json_request, from endpoints.api import (truthy_bool, format_date, nickname, log_action, validate_json_request,
require_repo_read, require_repo_write, require_repo_admin, require_repo_read, require_repo_write, require_repo_admin,
RepositoryParamResource, resource, query_param, parse_args, ApiResource, RepositoryParamResource, resource, query_param, parse_args, ApiResource,
@ -353,9 +353,14 @@ class Repository(RepositoryParamResource):
""" Delete a repository. """ """ Delete a repository. """
model.repository.purge_repository(namespace, repository) model.repository.purge_repository(namespace, repository)
user = model.user.get_namespace_user(namespace) user = model.user.get_namespace_user(namespace)
if features.BILLING: if features.BILLING:
plan = get_namespace_plan(namespace) plan = get_namespace_plan(namespace)
check_repository_usage(user, plan) check_repository_usage(user, plan)
# Remove any builds from the queue.
dockerfile_build_queue.delete_namespaced_items(namespace, repository)
log_action('delete_repo', namespace, log_action('delete_repo', namespace,
{'repo': repository, 'namespace': namespace}) {'repo': repository, 'namespace': namespace})
return 'Deleted', 204 return 'Deleted', 204

View file

@ -11,7 +11,8 @@ from flask import request, make_response, jsonify
import features import features
from app import app, avatar, superusers, authentication, config_provider, license_validator from app import (app, avatar, superusers, authentication, config_provider, license_validator,
all_queues)
from auth import scopes from auth import scopes
from auth.auth_context import get_authenticated_user from auth.auth_context import get_authenticated_user
from auth.permissions import SuperUserPermission from auth.permissions import SuperUserPermission
@ -366,7 +367,7 @@ class SuperUserManagement(ApiResource):
if superusers.is_superuser(username): if superusers.is_superuser(username):
abort(403) abort(403)
model.user.delete_user(user) model.user.delete_user(user, all_queues, force=True)
return 'Deleted', 204 return 'Deleted', 204
abort(403) abort(403)
@ -500,7 +501,7 @@ class SuperUserOrganizationManagement(ApiResource):
if SuperUserPermission().can(): if SuperUserPermission().can():
org = model.organization.get_organization(name) org = model.organization.get_organization(name)
model.user.delete_user(org) model.user.delete_user(org, all_queues)
return 'Deleted', 204 return 'Deleted', 204
abort(403) abort(403)

View file

@ -10,7 +10,7 @@ from peewee import IntegrityError
import features import features
from app import app, billing as stripe, authentication, avatar, user_analytics from app import app, billing as stripe, authentication, avatar, user_analytics, all_queues
from auth import scopes from auth import scopes
from auth.auth_context import get_authenticated_user from auth.auth_context import get_authenticated_user
from auth.permissions import (AdministerOrganizationPermission, CreateRepositoryPermission, from auth.permissions import (AdministerOrganizationPermission, CreateRepositoryPermission,
@ -346,9 +346,11 @@ class User(ApiResource):
@validate_json_request('NewUser') @validate_json_request('NewUser')
def post(self): def post(self):
""" Create a new user. """ """ Create a new user. """
if app.config['AUTHENTICATION_TYPE'] != 'Database':
abort(404)
user_data = request.get_json() user_data = request.get_json()
invite_code = user_data.get('invite_code', '') invite_code = user_data.get('invite_code', '')
existing_user = model.user.get_nonrobot_user(user_data['username']) existing_user = model.user.get_nonrobot_user(user_data['username'])
if existing_user: if existing_user:
raise request_error(message='The username already exists') raise request_error(message='The username already exists')
@ -373,6 +375,19 @@ class User(ApiResource):
except model.user.DataModelException as ex: except model.user.DataModelException as ex:
raise request_error(exception=ex) raise request_error(exception=ex)
@require_user_admin
@require_fresh_login
@nickname('deleteCurrentUser')
@internal_only
def delete(self):
""" Deletes the current user. """
if app.config['AUTHENTICATION_TYPE'] != 'Database':
abort(404)
model.user.delete_user(get_authenticated_user(), all_queues)
return 'Deleted', 204
@resource('/v1/user/private') @resource('/v1/user/private')
@internal_only @internal_only
@show_if(features.BILLING) @show_if(features.BILLING)

View file

@ -1651,3 +1651,12 @@ a:focus {
overflow-x: hidden; overflow-x: hidden;
max-height: 400px; max-height: 400px;
} }
.cor-confirm-dialog-element .modal-body {
padding: 20px;
}
.cor-confirm-dialog-element .progress-message {
margin-bottom: 10px;
font-size: 16px;
}

View file

@ -0,0 +1,3 @@
.delete-namespace-view-element .yellow {
color: #FCA657;
}

View file

@ -8,13 +8,19 @@
<h4 class="modal-title">{{ dialogTitle }}</h4> <h4 class="modal-title">{{ dialogTitle }}</h4>
</div> </div>
<div class="modal-body" ng-show="working"> <div class="modal-body" ng-show="working">
<div class="cor-loader"></div> <div class="cor-loader" ng-if="!dialogContext.progress"></div>
<div class="progress-message" ng-if="dialogContext.progressMessage">
{{ dialogContext.progressMessage }}
</div>
<div class="cor-progress-bar" ng-if="dialogContext.progress" progress="dialogContext.progress">
</div>
</div> </div>
<div class="modal-body" ng-show="!working"> <div class="modal-body" ng-show="!working">
<span ng-transclude/> <span ng-transclude/>
</div> </div>
<div class="modal-footer" ng-show="!working"> <div class="modal-footer" ng-show="!working">
<button type="button" class="btn btn-primary" ng-click="performAction()" ng-disabled="dialogForm && dialogForm.$invalid"> <button type="button" class="btn btn-primary" ng-class="dialogButtonClass || 'btn-primary'"
ng-click="performAction()" ng-disabled="dialogForm && dialogForm.$invalid">
{{ dialogActionTitle }} {{ dialogActionTitle }}
</button> </button>
<button type="button" class="btn btn-default" data-dismiss="modal">Cancel</button> <button type="button" class="btn btn-default" data-dismiss="modal">Cancel</button>

View file

@ -0,0 +1,4 @@
<div class="cor-progress-bar-element progress">
<div class="progress-bar" ng-style="{'width': (progress * 100) + '%'}"
aria-valuenow="{{ (progress * 100) }}" aria-valuemin="0" aria-valuemax="100"></div>
</div>

View file

@ -0,0 +1,35 @@
<div class="delete-namespace-view-element" quay-show="!Features.BILLING || subscriptionStatus != 'loading'">
<table class="co-list-table">
<tr>
<td>Delete Account:</td>
<td quay-show="!Features.BILLING || subscriptionStatus == 'none'">
<a class="co-modify-link" ng-click="showDeleteNamespace()">Begin deletion</a>
</td>
<td quay-show="Features.BILLING && subscriptionStatus == 'valid'">
<i class="fa fa-exclamation-triangle yellow"></i> You must cancel your billing subscription before this account can be deleted.
</td>
</tr>
</table>
<!-- Delete account dialog -->
<div class="cor-confirm-dialog"
dialog-context="deleteNamespaceInfo"
dialog-action="deleteNamespace(info, callback)"
dialog-title="Delete Account"
dialog-action-title="Delete Account"
dialog-form="context.deleteform"
dialog-button-class="btn-danger">
<form name="context.deleteform" class="co-single-field-dialog">
<div class="co-alert co-alert-danger">
Deleting an account is <strong>non-reversable</strong> and will delete
<strong>all of the account's data</strong> including repositories, created build triggers,
and notifications.
</div>
You must type <code>{{ deleteNamespaceInfo.namespace }}</code> below to confirm deletion is requested:
<input type="text" class="form-control" placeholder="Enter namespace here"
ng-model="deleteNamespaceInfo.verification" ng-pattern="deleteNamespaceInfo.namespace"
required>
</form>
</div>
</div>

View file

@ -173,6 +173,7 @@ angular.module("core-ui", [])
'dialogTitle': '@dialogTitle', 'dialogTitle': '@dialogTitle',
'dialogActionTitle': '@dialogActionTitle', 'dialogActionTitle': '@dialogActionTitle',
'dialogForm': '=dialogForm', 'dialogForm': '=dialogForm',
'dialogButtonClass': '@dialogButtonClass',
'dialogContext': '=dialogContext', 'dialogContext': '=dialogContext',
'dialogAction': '&dialogAction' 'dialogAction': '&dialogAction'
@ -614,6 +615,22 @@ angular.module("core-ui", [])
return directiveDefinitionObject; return directiveDefinitionObject;
}) })
.directive('corProgressBar', function() {
var directiveDefinitionObject = {
priority: 4,
templateUrl: '/static/directives/cor-progress-bar.html',
replace: true,
transclude: true,
restrict: 'C',
scope: {
'progress': '=progress'
},
controller: function($rootScope, $scope, $element) {
}
};
return directiveDefinitionObject;
})
.directive('corStepBar', function() { .directive('corStepBar', function() {
var directiveDefinitionObject = { var directiveDefinitionObject = {
priority: 4, priority: 4,

View file

@ -11,7 +11,8 @@ angular.module('quay').directive('billingManagementPanel', function () {
scope: { scope: {
'user': '=user', 'user': '=user',
'organization': '=organization', 'organization': '=organization',
'isEnabled': '=isEnabled' 'isEnabled': '=isEnabled',
'subscriptionStatus': '=subscriptionStatus'
}, },
controller: function($scope, $element, PlanService, ApiService, Features) { controller: function($scope, $element, PlanService, ApiService, Features) {
$scope.currentCard = null; $scope.currentCard = null;
@ -19,6 +20,7 @@ angular.module('quay').directive('billingManagementPanel', function () {
$scope.updating = true; $scope.updating = true;
$scope.changeReceiptsInfo = null; $scope.changeReceiptsInfo = null;
$scope.context = {}; $scope.context = {};
$scope.subscriptionStatus = 'loading';
var setSubscription = function(sub) { var setSubscription = function(sub) {
$scope.subscription = sub; $scope.subscription = sub;
@ -29,12 +31,14 @@ angular.module('quay').directive('billingManagementPanel', function () {
if (!sub.hasSubscription) { if (!sub.hasSubscription) {
$scope.updating = false; $scope.updating = false;
$scope.subscriptionStatus = 'none';
return; return;
} }
// Load credit card information. // Load credit card information.
PlanService.getCardInfo($scope.organization ? $scope.organization.name : null, function(card) { PlanService.getCardInfo($scope.organization ? $scope.organization.name : null, function(card) {
$scope.currentCard = card; $scope.currentCard = card;
$scope.subscriptionStatus = 'valid';
$scope.updating = false; $scope.updating = false;
}); });
}); });

View file

@ -0,0 +1,34 @@
/**
* An element which displays a settings table row for deleting a namespace (user or organization).
*/
angular.module('quay').directive('deleteNamespaceView', function () {
var directiveDefinitionObject = {
priority: 0,
templateUrl: '/static/directives/delete-namespace-view.html',
replace: false,
transclude: true,
restrict: 'C',
scope: {
'user': '=user',
'organization': '=organization',
'subscriptionStatus': '=subscriptionStatus'
},
controller: function($scope, $element, UserService) {
$scope.context = {};
$scope.showDeleteNamespace = function() {
$scope.deleteNamespaceInfo = {
'user': $scope.user,
'organization': $scope.organization,
'namespace': $scope.user ? $scope.user.username : $scope.organization.name,
'verification': ''
};
};
$scope.deleteNamespace = function(info, callback) {
UserService.deleteNamespace(info, callback);
};
}
};
return directiveDefinitionObject;
});

View file

@ -20,6 +20,7 @@
$scope.showRobotsCounter = 0; $scope.showRobotsCounter = 0;
$scope.showTeamsCounter = 0; $scope.showTeamsCounter = 0;
$scope.changeEmailInfo = null; $scope.changeEmailInfo = null;
$scope.context = {};
$scope.orgScope = { $scope.orgScope = {
'changingOrganization': false, 'changingOrganization': false,

View file

@ -3,9 +3,9 @@
* about the user. * about the user.
*/ */
angular.module('quay') angular.module('quay')
.factory('UserService', ['ApiService', 'CookieService', '$rootScope', 'Config', .factory('UserService', ['ApiService', 'CookieService', '$rootScope', 'Config', '$location',
function(ApiService, CookieService, $rootScope, Config) { function(ApiService, CookieService, $rootScope, Config, $location) {
var userResponse = { var userResponse = {
verified: false, verified: false,
anonymous: true, anonymous: true,
@ -169,6 +169,69 @@ function(ApiService, CookieService, $rootScope, Config) {
return externalUsername || userResponse.username; return externalUsername || userResponse.username;
}; };
userService.deleteNamespace = function(info, callback) {
var namespace = info.user ? info.user.username : info.organization.name;
var deleteNamespaceItself = function() {
info.progress = 1;
info.progressMessage = 'Deleting namespace...';
if (info.user) {
ApiService.deleteCurrentUser().then(function(resp) {
// Reload the user.
userService.load();
callback(true);
$location.path('/');
}, errorDisplay);
} else {
var delParams = {
'name': info.organization.name
};
ApiService.deleteOrganization(null, delParams).then(function(resp) {
// Reload the user.
userService.load();
callback(true);
$location.path('/');
}, errorDisplay);
}
};
var repoIndex = 0;
var repositories = null;
var deleteAllRepos = function() {
if (repoIndex >= repositories.length) {
deleteNamespaceItself();
return;
}
var repoParams = {
'repository': namespace + '/' + repositories[repoIndex]['name']
};
info.progress = repoIndex / repositories.length;
info.progressMessage = 'Deleting repository ' + repoParams['repository'] + '...';
ApiService.deleteRepository(null, repoParams).then(function() {
repoIndex++;
deleteAllRepos();
}, errorDisplay);
};
// First delete each repo for the namespace, updating the info so it can show a progress bar.
// This is not strictly necessary (as the namespace delete call will do it as well), but it is
// a better user experience.
var params = {
'namespace': namespace,
'public': false
};
var errorDisplay = ApiService.errorDisplay('Could not delete namespace', callback);
ApiService.listRepos(null, params).then(function(resp) {
repositories = resp['repositories'];
deleteAllRepos();
}, errorDisplay);
};
userService.currentUser = function() { userService.currentUser = function() {
return userResponse; return userResponse;
}; };

View file

@ -116,12 +116,14 @@
</td> </td>
</tr> </tr>
</table> </table>
<div class="delete-namespace-view" subscription-status="subscriptionStatus" organization="organization"></div>
</div> </div>
<!-- Billing Information --> <!-- Billing Information -->
<div class="settings-section" quay-show="Features.BILLING"> <div class="settings-section" quay-show="Features.BILLING">
<h3>Billing Information</h3> <h3>Billing Information</h3>
<div class="billing-management-panel" organization="organization" is-enabled="showBillingCounter"></div> <div class="billing-management-panel" organization="organization" is-enabled="showBillingCounter" subscription-status="subscriptionStatus"></div>
</div> </div>
</div> </div>
</div> </div>
@ -129,7 +131,6 @@
</div> </div>
</div> </div>
<!-- Change email dialog --> <!-- Change email dialog -->
<div class="cor-confirm-dialog" <div class="cor-confirm-dialog"
dialog-context="changeEmailInfo" dialog-context="changeEmailInfo"

View file

@ -133,12 +133,15 @@
</td> </td>
</tr> </tr>
</table> </table>
<div class="delete-namespace-view" subscription-status="subscriptionStatus" user="context.viewuser"
quay-show="Config.AUTHENTICATION_TYPE == 'Database'"></div>
</div> </div>
<!-- Billing Information --> <!-- Billing Information -->
<div class="settings-section" quay-show="Features.BILLING"> <div class="settings-section" quay-show="Features.BILLING">
<h3>Billing Information</h3> <h3>Billing Information</h3>
<div class="billing-management-panel" user="context.viewuser" is-enabled="showBillingCounter"></div> <div class="billing-management-panel" user="context.viewuser" is-enabled="showBillingCounter" subscription-status="subscriptionStatus"></div>
</div> </div>
</div> <!-- /cor-tab-content --> </div> <!-- /cor-tab-content -->

View file

@ -21,7 +21,7 @@ from mockldap import MockLdap
from endpoints.api import api_bp, api from endpoints.api import api_bp, api
from endpoints.building import PreparedBuild from endpoints.building import PreparedBuild
from endpoints.webhooks import webhooks from endpoints.webhooks import webhooks
from app import app, config_provider, notification_queue from app import app, config_provider, all_queues, dockerfile_build_queue, notification_queue
from buildtrigger.basehandler import BuildTriggerHandler from buildtrigger.basehandler import BuildTriggerHandler
from initdb import setup_database_for_testing, finished_database_for_testing from initdb import setup_database_for_testing, finished_database_for_testing
from data import database, model from data import database, model
@ -581,7 +581,6 @@ class TestCreateNewUser(ApiTestCase):
teamname='owners')) teamname='owners'))
self.assertNotInTeam(json, NEW_USER_DETAILS['username']) self.assertNotInTeam(json, NEW_USER_DETAILS['username'])
def test_createuser_withteaminvite_differentemails(self): def test_createuser_withteaminvite_differentemails(self):
inviter = model.user.get_user(ADMIN_ACCESS_USER) inviter = model.user.get_user(ADMIN_ACCESS_USER)
team = model.team.get_organization_team(ORGANIZATION, 'owners') team = model.team.get_organization_team(ORGANIZATION, 'owners')
@ -606,6 +605,32 @@ class TestCreateNewUser(ApiTestCase):
self.assertNotInTeam(json, NEW_USER_DETAILS['username']) self.assertNotInTeam(json, NEW_USER_DETAILS['username'])
class TestDeleteNamespace(ApiTestCase):
def test_deletenamespaces(self):
self.login(ADMIN_ACCESS_USER)
# Try to first delete the user. Since they are the sole admin of two orgs, it should fail.
with check_transitive_deletes():
self.deleteResponse(User, expected_code=400)
# Delete the two orgs, checking in between.
with check_transitive_deletes():
self.deleteResponse(Organization, params=dict(orgname=ORGANIZATION), expected_code=204)
self.deleteResponse(User, expected_code=400) # Should still fail.
self.deleteResponse(Organization, params=dict(orgname='library'), expected_code=204)
# Add some queue items for the user.
notification_queue.put([ADMIN_ACCESS_USER, 'somerepo', 'somename'], '{}')
dockerfile_build_queue.put([ADMIN_ACCESS_USER, 'anotherrepo'], '{}')
# Now delete the user.
with check_transitive_deletes():
self.deleteResponse(User, expected_code=204)
# Ensure the queue items are gone.
self.assertIsNone(notification_queue.get())
self.assertIsNone(dockerfile_build_queue.get())
class TestSignin(ApiTestCase): class TestSignin(ApiTestCase):
def test_signin_unicode(self): def test_signin_unicode(self):
@ -1798,13 +1823,37 @@ class TestDeleteRepository(ApiTestCase):
self.getResponse(Repository, self.getResponse(Repository,
params=dict(repository=self.SIMPLE_REPO)) params=dict(repository=self.SIMPLE_REPO))
# Add a build queue item for the repo.
dockerfile_build_queue.put([ADMIN_ACCESS_USER, 'simple'], '{}')
# Delete the repository.
self.deleteResponse(Repository, params=dict(repository=self.SIMPLE_REPO)) self.deleteResponse(Repository, params=dict(repository=self.SIMPLE_REPO))
# Ensure the queue item is gone.
self.assertIsNone(dockerfile_build_queue.get())
# Verify the repo was deleted. # Verify the repo was deleted.
self.getResponse(Repository, self.getResponse(Repository,
params=dict(repository=self.SIMPLE_REPO), params=dict(repository=self.SIMPLE_REPO),
expected_code=404) expected_code=404)
def test_verify_queue_removal(self):
self.login(ADMIN_ACCESS_USER)
# Verify the repo exists.
self.getResponse(Repository,
params=dict(repository=self.SIMPLE_REPO))
# Add a build queue item for the repo and another repo.
dockerfile_build_queue.put([ADMIN_ACCESS_USER, 'simple'], '{}')
dockerfile_build_queue.put([ADMIN_ACCESS_USER, 'anotherrepo'], '{}')
# Delete the repository.
self.deleteResponse(Repository, params=dict(repository=self.SIMPLE_REPO))
# Ensure the other queue item is still present.
self.assertIsNotNone(dockerfile_build_queue.get())
def test_deleterepo2(self): def test_deleterepo2(self):
self.login(ADMIN_ACCESS_USER) self.login(ADMIN_ACCESS_USER)
@ -3717,7 +3766,7 @@ class TestSuperUserCreateInitialSuperUser(ApiTestCase):
# Delete all users in the DB. # Delete all users in the DB.
for user in list(database.User.select()): for user in list(database.User.select()):
user.delete_instance(recursive=True) model.user.delete_user(user, all_queues, force=True)
# Create the superuser. # Create the superuser.
self.postJsonResponse(SuperUserCreateInitialSuperUser, data=data) self.postJsonResponse(SuperUserCreateInitialSuperUser, data=data)

View file

@ -1,8 +1,9 @@
from test.test_api_usage import ApiTestCase, READ_ACCESS_USER, ADMIN_ACCESS_USER from test.test_api_usage import ApiTestCase, READ_ACCESS_USER, ADMIN_ACCESS_USER
from endpoints.api.suconfig import (SuperUserRegistryStatus, SuperUserConfig, SuperUserConfigFile, from endpoints.api.suconfig import (SuperUserRegistryStatus, SuperUserConfig, SuperUserConfigFile,
SuperUserCreateInitialSuperUser, SuperUserConfigValidate) SuperUserCreateInitialSuperUser, SuperUserConfigValidate)
from app import config_provider from app import config_provider, all_queues
from data.database import User from data.database import User
from data import model
import unittest import unittest
@ -95,7 +96,7 @@ class TestSuperUserCreateInitialSuperUser(ApiTestCase):
# Delete all the users in the DB. # Delete all the users in the DB.
for user in list(User.select()): for user in list(User.select()):
user.delete_instance(recursive=True) model.user.delete_user(user, all_queues, force=True)
# This method should now succeed. # This method should now succeed.
data = dict(username='cooluser', password='password', email='fake@example.com') data = dict(username='cooluser', password='password', email='fake@example.com')