Add ability for super users to take ownership of namespaces

Fixes #1395
This commit is contained in:
Joseph Schorr 2016-06-07 18:12:11 -04:00
parent f75949d533
commit 20816804e5
14 changed files with 280 additions and 94 deletions

View file

@ -0,0 +1,26 @@
"""Add take_ownership log entry kind
Revision ID: 0f17d94d11eb
Revises: a3ba52d02dec
Create Date: 2016-06-07 17:22:20.438873
"""
# revision identifiers, used by Alembic.
revision = '0f17d94d11eb'
down_revision = 'a3ba52d02dec'
from alembic import op
def upgrade(tables):
op.bulk_insert(tables.logentrykind,
[
{'name':'take_ownership'},
])
def downgrade(tables):
op.execute(
(tables.logentrykind.delete()
.where(tables.logentrykind.c.name == op.inline_literal('take_ownership')))
)

View file

@ -136,3 +136,11 @@ def get_all_repo_users_transitive_via_teams(namespace_name, repository_name):
def get_organizations():
return User.select().where(User.organization == True, User.robot == False)
def add_user_as_admin(user_obj, org_obj):
try:
admin_role = TeamRole.get(name='admin')
admin_team = Team.select().where(Team.role == admin_role, Team.organization == org_obj).get()
team.add_user_to_team(user_obj, admin_team)
except team.UserAlreadyInTeam:
pass

View file

@ -1,4 +1,4 @@
from data.database import Team, TeamMember, TeamRole, User, TeamMemberInvite, Repository
from data.database import Team, TeamMember, TeamRole, User, TeamMemberInvite
from data.model import (DataModelException, InvalidTeamException, UserAlreadyInTeam,
InvalidTeamMemberException, user, _basequery)
from util.validation import validate_username

View file

@ -422,6 +422,54 @@ class SuperUserManagement(ApiResource):
abort(403)
@resource('/v1/superuser/takeownership/<namespace>')
@path_param('namespace', 'The namespace of the user or organization being managed')
@internal_only
@show_if(features.SUPER_USERS)
class SuperUserTakeOwnership(ApiResource):
""" Resource for a superuser to take ownership of a namespace. """
@require_fresh_login
@verify_not_prod
@nickname('takeOwnership')
@require_scope(scopes.SUPERUSER)
def post(self, namespace):
""" Takes ownership of the specified organization or user. """
if SuperUserPermission().can():
# Disallow for superusers.
if superusers.is_superuser(namespace):
abort(400)
entity = model.user.get_user_or_org(namespace)
if entity is None:
abort(404)
authed_user = get_authenticated_user()
was_user = not entity.organization
if entity.organization:
# Add the superuser as an admin to the owners team of the org.
model.organization.add_user_as_admin(authed_user, entity)
else:
# If the entity is a user, convert it to an organization and add the current superuser
# as the admin.
model.organization.convert_user_to_organization(entity, get_authenticated_user())
# Log the change.
log_metadata = {
'entity_id': entity.id,
'namespace': namespace,
'was_user': was_user,
'superuser': authed_user.username,
}
log_action('take_ownership', authed_user.username, log_metadata)
return jsonify({
'namespace': namespace
})
abort(403)
@resource('/v1/superuser/organizations/<name>')
@path_param('name', 'The name of the organizaton being managed')
@internal_only

View file

@ -343,6 +343,8 @@ def initialize_database():
LogEntryKind.create(name='service_key_extend')
LogEntryKind.create(name='service_key_rotate')
LogEntryKind.create(name='take_ownership')
ImageStorageLocation.create(name='local_eu')
ImageStorageLocation.create(name='local_us')

View file

@ -49,3 +49,11 @@
.super-user .input-util {
margin-top: 10px;
}
.super-user .take-ownership-dialog .avatar {
margin-left: 6px;
}
.super-user .take-ownership-dialog .co-alert {
margin-top: 20px;
}

View file

@ -223,6 +223,14 @@ angular.module('quay').directive('logsView', function () {
'service_key_extend': 'Change of expiration of service key {kid} from {old_expiration_date} to {expiration_date}',
'service_key_rotate': 'Automatic rotation of service key {kid} by {user_agent}',
'take_ownership': function(metadata) {
if (metadata.was_user) {
return 'Superuser {superuser} took ownership of user namespace {namespace}';
} else {
return 'Superuser {superuser} took ownership of organization {namespace}';
}
},
// Note: These are deprecated.
'add_repo_webhook': 'Add webhook in repository {repo}',
'delete_repo_webhook': 'Delete webhook in repository {repo}'
@ -279,6 +287,7 @@ angular.module('quay').directive('logsView', function () {
'service_key_delete': 'Delete Service Key',
'service_key_extend': 'Extend Service Key Expiration',
'service_key_rotate': 'Automatic rotation of Service Key',
'take_ownership': 'Take Namespace Ownership',
// Note: these are deprecated.
'add_repo_webhook': 'Add webhook',

View file

@ -10,7 +10,7 @@
})
}]);
function SuperuserCtrl($scope, $timeout, ApiService, Features, UserService, ContainerService, AngularPollChannel, CoreDialog) {
function SuperuserCtrl($scope, $timeout, $location, ApiService, Features, UserService, ContainerService, AngularPollChannel, CoreDialog) {
if (!Features.SUPER_USERS) {
return;
}
@ -32,6 +32,7 @@
$scope.dashboardActive = false;
$scope.currentConfig = null;
$scope.serviceKeysActive = false;
$scope.takeOwnershipInfo = null;
$scope.setDashboardActive = function(active) {
$scope.dashboardActive = active;
@ -261,6 +262,25 @@
});
};
$scope.askTakeOwnership = function(entity, is_org) {
$scope.takeOwnershipInfo = {
'entity': entity,
'is_org': is_org
};
};
$scope.takeOwnership = function(info, callback) {
var errorDisplay = ApiService.errorDisplay('Could not take ownership of namespace', callback);
var params = {
'namespace': info.entity.username || info.entity.name
};
ApiService.takeOwnership(null, params).then(function() {
callback(true);
$location.path('/organization/' + params.namespace);
}, errorDisplay)
};
$scope.askDisableUser = function(user) {
var message = 'Are you sure you want to disable this user? ' +
'They will be unable to login, pull or push.'

View file

@ -138,6 +138,9 @@
<span class="cor-option" option-click="askDeleteOrganization(current_org)">
<i class="fa fa-times"></i> Delete Organization
</span>
<span class="cor-option" option-click="askTakeOwnership(current_org, true)">
<i class="fa fa-bolt"></i> Take Ownership
</span>
</span>
</td>
</tr>
@ -223,6 +226,10 @@
<span class="cor-option" option-click="askDisableUser(current_user)">
<i class="fa" ng-class="current_user.enabled ? 'fa-circle-o' : 'fa-check-circle-o'"></i> <span ng-if="current_user.enabled">Disable</span> <span ng-if="!current_user.enabled">Enable</span> User
</span>
<span class="cor-option" option-click="askTakeOwnership(current_user, false)"
ng-if="user.username != current_user.username && !current_user.super_user">
<i class="fa fa-bolt"></i> Take Ownership
</span>
</span>
</td>
</tr>
@ -233,6 +240,21 @@
</div> <!-- /cor-tab-content -->
</div> <!-- /cor-tab-panel -->
<!-- Take ownership dialog -->
<div class="cor-confirm-dialog take-ownership-dialog"
dialog-context="takeOwnershipInfo"
dialog-action="takeOwnership(info, callback)"
dialog-title="Take Ownership"
dialog-action-title="Take Ownership">
Are you sure you want to take ownership of
<span ng-if="takeOwnershipInfo.is_org">organization <span class="avatar" data="takeOwnershipInfo.entity.avatar" size="16"></span> {{ takeOwnershipInfo.entity.name }}?</span>
<span ng-if="!takeOwnershipInfo.is_org">user namespace <span class="avatar" data="takeOwnershipInfo.entity.avatar" size="16"></span> {{ takeOwnershipInfo .entity.username }}?</span>
<div class="co-alert co-alert-warning" ng-if="!takeOwnershipInfo.is_org">
Note: This will convert the user namespace into an organization. <strong>The user will no longer be able to login to this account.</strong>
</div>
</div>
<!-- Modal message dialog -->
<div class="co-dialog modal fade" id="confirmDeleteUserModal">
<div class="modal-dialog">

Binary file not shown.

View file

@ -9,7 +9,7 @@ class assert_action_logged(object):
self.existing_count = 0
def _get_log_count(self):
return LogEntry.select(LogEntry.kind == LogEntryKind.get(name=self.log_kind)).count()
return LogEntry.select().where(LogEntry.kind == LogEntryKind.get(name=self.log_kind)).count()
def __enter__(self):
self.existing_count = self._get_log_count()

View file

@ -49,7 +49,8 @@ from endpoints.api.superuser import (SuperUserLogs, SuperUserList, SuperUserMana
SuperUserSendRecoveryEmail, ChangeLog,
SuperUserOrganizationManagement, SuperUserOrganizationList,
SuperUserAggregateLogs, SuperUserServiceKeyManagement,
SuperUserServiceKey, SuperUserServiceKeyApproval)
SuperUserServiceKey, SuperUserServiceKeyApproval,
SuperUserTakeOwnership)
from endpoints.api.secscan import RepositoryImageSecurity
@ -3912,6 +3913,24 @@ class TestSuperUserSendRecoveryEmail(ApiTestCase):
self._run_test('POST', 404, 'devtable', None)
class TestSuperUserTakeOwnership(ApiTestCase):
def setUp(self):
ApiTestCase.setUp(self)
self._set_url(SuperUserTakeOwnership, namespace='invalidnamespace')
def test_post_anonymous(self):
self._run_test('POST', 401, None, {})
def test_post_freshuser(self):
self._run_test('POST', 403, 'freshuser', {})
def test_post_reader(self):
self._run_test('POST', 403, 'reader', {})
def test_post_devtable(self):
self._run_test('POST', 404, 'devtable', {})
class TestSuperUserServiceKeyApproval(ApiTestCase):
def setUp(self):
ApiTestCase.setUp(self)
@ -3930,7 +3949,6 @@ class TestSuperUserServiceKeyApproval(ApiTestCase):
self._run_test('POST', 404, 'devtable', {})
class TestSuperUserServiceKeyManagement(ApiTestCase):
def setUp(self):
ApiTestCase.setUp(self)

View file

@ -24,7 +24,8 @@ from app import app, config_provider
from buildtrigger.basehandler import BuildTriggerHandler
from initdb import setup_database_for_testing, finished_database_for_testing
from data import database, model
from data.database import RepositoryActionCount, LogEntry, LogEntryKind
from data.database import RepositoryActionCount
from test.helpers import assert_action_logged
from endpoints.api.team import TeamMember, TeamMemberList, TeamMemberInvite, OrganizationTeam
from endpoints.api.tag import RepositoryTagImages, RepositoryTag, RevertTag, ListRepositoryTags
@ -59,7 +60,7 @@ from endpoints.api.permission import (RepositoryUserPermission, RepositoryTeamPe
RepositoryTeamPermissionList, RepositoryUserPermissionList)
from endpoints.api.superuser import (SuperUserLogs, SuperUserList, SuperUserManagement,
SuperUserServiceKeyManagement, SuperUserServiceKey,
SuperUserServiceKeyApproval)
SuperUserServiceKeyApproval, SuperUserTakeOwnership)
from endpoints.api.secscan import RepositoryImageSecurity
from endpoints.api.suconfig import (SuperUserRegistryStatus, SuperUserConfig, SuperUserConfigFile,
SuperUserCreateInitialSuperUser)
@ -3650,13 +3651,62 @@ class TestRepositoryImageSecurity(ApiTestCase):
self.assertEquals(1, response['data']['Layer']['IndexedByVersion'])
class TestSuperUserTakeOwnership(ApiTestCase):
def test_take_ownership_superuser(self):
self.login(ADMIN_ACCESS_USER)
# Should fail to take ownership of a superuser.
self.postResponse(SuperUserTakeOwnership, params=dict(namespace=ADMIN_ACCESS_USER),
expected_code=400)
def test_take_ownership_invalid_namespace(self):
self.login(ADMIN_ACCESS_USER)
self.postResponse(SuperUserTakeOwnership, params=dict(namespace='invalid'),
expected_code=404)
def test_take_ownership_non_superuser(self):
self.login(READ_ACCESS_USER)
self.postResponse(SuperUserTakeOwnership, params=dict(namespace='freshuser'),
expected_code=403)
def test_take_ownership_user(self):
self.login(ADMIN_ACCESS_USER)
with assert_action_logged('take_ownership'):
# Take ownership of the read user.
self.postResponse(SuperUserTakeOwnership, params=dict(namespace=READ_ACCESS_USER))
# Ensure that the read access user is now an org, with the superuser as the owner.
reader = model.user.get_user_or_org(READ_ACCESS_USER)
self.assertTrue(reader.organization)
usernames = [admin.username for admin in model.organization.get_admin_users(reader)]
self.assertIn(ADMIN_ACCESS_USER, usernames)
def test_take_ownership_org(self):
# Create a new org with another user as owner.
public_user = model.user.get_user(PUBLIC_USER)
org = model.organization.create_organization('someorg', 'some@example.com', public_user)
# Ensure that the admin is not yet owner of the org.
usernames = [admin.username for admin in model.organization.get_admin_users(org)]
self.assertNotIn(ADMIN_ACCESS_USER, usernames)
with assert_action_logged('take_ownership'):
# Take ownership.
self.login(ADMIN_ACCESS_USER)
self.postResponse(SuperUserTakeOwnership, params=dict(namespace='someorg'))
# Ensure now in the admin users.
usernames = [admin.username for admin in model.organization.get_admin_users(org)]
self.assertIn(ADMIN_ACCESS_USER, usernames)
class TestSuperUserKeyManagement(ApiTestCase):
def test_get_update_keys(self):
self.login(ADMIN_ACCESS_USER)
kind = LogEntryKind.get(LogEntryKind.name == 'service_key_modify')
existing_modify = model.log.LogEntry.select().where(LogEntry.kind == kind).count()
json = self.getJsonResponse(SuperUserServiceKeyManagement)
key_count = len(json['keys'])
@ -3670,81 +3720,65 @@ class TestSuperUserKeyManagement(ApiTestCase):
self.assertTrue('approval' in key)
self.assertTrue('metadata' in key)
# Update the key's name.
self.putJsonResponse(SuperUserServiceKey, params=dict(kid=key['kid']),
data=dict(name='somenewname'))
with assert_action_logged('service_key_modify'):
# Update the key's name.
self.putJsonResponse(SuperUserServiceKey, params=dict(kid=key['kid']),
data=dict(name='somenewname'))
# Ensure the key's name has been changed.
json = self.getJsonResponse(SuperUserServiceKey, params=dict(kid=key['kid']))
self.assertEquals('somenewname', json['name'])
# Ensure the key's name has been changed.
json = self.getJsonResponse(SuperUserServiceKey, params=dict(kid=key['kid']))
self.assertEquals('somenewname', json['name'])
# Ensure a log was added for the modification.
kind = LogEntryKind.get(LogEntryKind.name == 'service_key_modify')
self.assertEquals(existing_modify + 1, model.log.LogEntry.select().where(LogEntry.kind == kind).count())
with assert_action_logged('service_key_modify'):
# Update the key's metadata.
self.putJsonResponse(SuperUserServiceKey, params=dict(kid=key['kid']),
data=dict(metadata=dict(foo='bar')))
# Update the key's metadata.
self.putJsonResponse(SuperUserServiceKey, params=dict(kid=key['kid']),
data=dict(metadata=dict(foo='bar')))
# Ensure the key's metadata has been changed.
json = self.getJsonResponse(SuperUserServiceKey, params=dict(kid=key['kid']))
self.assertEquals('bar', json['metadata']['foo'])
# Ensure the key's metadata has been changed.
json = self.getJsonResponse(SuperUserServiceKey, params=dict(kid=key['kid']))
self.assertEquals('bar', json['metadata']['foo'])
with assert_action_logged('service_key_extend'):
# Change the key's expiration.
self.putJsonResponse(SuperUserServiceKey, params=dict(kid=key['kid']),
data=dict(expiration=None))
# Ensure a log was added for the modification.
kind = LogEntryKind.get(LogEntryKind.name == 'service_key_modify')
self.assertEquals(existing_modify + 2, model.log.LogEntry.select().where(LogEntry.kind == kind).count())
# Ensure the key's expiration has been changed.
json = self.getJsonResponse(SuperUserServiceKey, params=dict(kid=key['kid']))
self.assertIsNone(json['expiration_date'])
# Change the key's expiration.
self.putJsonResponse(SuperUserServiceKey, params=dict(kid=key['kid']),
data=dict(expiration=None))
with assert_action_logged('service_key_delete'):
# Delete the key.
self.deleteResponse(SuperUserServiceKey, params=dict(kid=key['kid']))
# Ensure the key's expiration has been changed.
json = self.getJsonResponse(SuperUserServiceKey, params=dict(kid=key['kid']))
self.assertIsNone(json['expiration_date'])
# Ensure the key no longer exists.
self.getResponse(SuperUserServiceKey, params=dict(kid=key['kid']), expected_code=404)
# Ensure a log was added for the modification.
kind = LogEntryKind.get(LogEntryKind.name == 'service_key_extend')
self.assertEquals(1, model.log.LogEntry.select().where(LogEntry.kind == kind).count())
json = self.getJsonResponse(SuperUserServiceKeyManagement)
self.assertEquals(key_count - 1, len(json['keys']))
# Delete the key.
self.deleteResponse(SuperUserServiceKey, params=dict(kid=key['kid']))
# Ensure the key no longer exists.
self.getResponse(SuperUserServiceKey, params=dict(kid=key['kid']), expected_code=404)
json = self.getJsonResponse(SuperUserServiceKeyManagement)
self.assertEquals(key_count - 1, len(json['keys']))
# Ensure a log was added for the deletion.
kind = LogEntryKind.get(LogEntryKind.name == 'service_key_delete')
self.assertEquals(1, model.log.LogEntry.select().where(LogEntry.kind == kind).count())
def test_approve_key(self):
self.login(ADMIN_ACCESS_USER)
kind = LogEntryKind.get(LogEntryKind.name == 'service_key_approve')
existing_log_count = model.log.LogEntry.select().where(LogEntry.kind == kind).count()
# Ensure the key is not yet approved.
json = self.getJsonResponse(SuperUserServiceKey, params=dict(kid='kid3'))
self.assertEquals('unapprovedkey', json['name'])
self.assertIsNone(json['approval'])
# Approve the key.
self.postResponse(SuperUserServiceKeyApproval, params=dict(kid='kid3'),
data=dict(notes='testapprove'), expected_code=201)
with assert_action_logged('service_key_approve'):
self.postResponse(SuperUserServiceKeyApproval, params=dict(kid='kid3'),
data=dict(notes='testapprove'), expected_code=201)
# Ensure the key is approved.
json = self.getJsonResponse(SuperUserServiceKey, params=dict(kid='kid3'))
self.assertEquals('unapprovedkey', json['name'])
self.assertIsNotNone(json['approval'])
self.assertEquals('ServiceKeyApprovalType.SUPERUSER', json['approval']['approval_type'])
self.assertEquals(ADMIN_ACCESS_USER, json['approval']['approver']['username'])
self.assertEquals('testapprove', json['approval']['notes'])
# Ensure the key is approved.
json = self.getJsonResponse(SuperUserServiceKey, params=dict(kid='kid3'))
self.assertEquals('unapprovedkey', json['name'])
self.assertIsNotNone(json['approval'])
self.assertEquals('ServiceKeyApprovalType.SUPERUSER', json['approval']['approval_type'])
self.assertEquals(ADMIN_ACCESS_USER, json['approval']['approver']['username'])
self.assertEquals('testapprove', json['approval']['notes'])
# Ensure the approval was logged.
kind = LogEntryKind.get(LogEntryKind.name == 'service_key_approve')
self.assertEquals(existing_log_count + 1, model.log.LogEntry.select().where(LogEntry.kind == kind).count())
def test_approve_preapproved(self):
self.login(ADMIN_ACCESS_USER)
@ -3766,9 +3800,6 @@ class TestSuperUserKeyManagement(ApiTestCase):
def test_create_key(self):
self.login(ADMIN_ACCESS_USER)
kind = LogEntryKind.get(LogEntryKind.name == 'service_key_create')
existing_log_count = model.log.LogEntry.select().where(LogEntry.kind == kind).count()
new_key = {
'service': 'coolservice',
'name': 'mynewkey',
@ -3777,36 +3808,30 @@ class TestSuperUserKeyManagement(ApiTestCase):
'expiration': timegm((datetime.datetime.now() + datetime.timedelta(days=1)).utctimetuple()),
}
# Create the key.
json = self.postJsonResponse(SuperUserServiceKeyManagement, data=new_key)
self.assertEquals('mynewkey', json['name'])
self.assertTrue('kid' in json)
self.assertTrue('public_key' in json)
self.assertTrue('private_key' in json)
with assert_action_logged('service_key_create'):
# Create the key.
json = self.postJsonResponse(SuperUserServiceKeyManagement, data=new_key)
self.assertEquals('mynewkey', json['name'])
self.assertTrue('kid' in json)
self.assertTrue('public_key' in json)
self.assertTrue('private_key' in json)
# Verify the private key is a valid PEM.
serialization.load_pem_private_key(json['private_key'].encode('utf-8'), None, default_backend())
# Verify the private key is a valid PEM.
serialization.load_pem_private_key(json['private_key'].encode('utf-8'), None, default_backend())
# Verify the key.
kid = json['kid']
# Verify the key.
kid = json['kid']
json = self.getJsonResponse(SuperUserServiceKey, params=dict(kid=kid))
self.assertEquals('mynewkey', json['name'])
self.assertEquals('coolservice', json['service'])
self.assertEquals('baz', json['metadata']['foo'])
self.assertEquals(kid, json['kid'])
json = self.getJsonResponse(SuperUserServiceKey, params=dict(kid=kid))
self.assertEquals('mynewkey', json['name'])
self.assertEquals('coolservice', json['service'])
self.assertEquals('baz', json['metadata']['foo'])
self.assertEquals(kid, json['kid'])
self.assertIsNotNone(json['approval'])
self.assertEquals('ServiceKeyApprovalType.SUPERUSER', json['approval']['approval_type'])
self.assertEquals(ADMIN_ACCESS_USER, json['approval']['approver']['username'])
self.assertEquals('whazzup!?', json['approval']['notes'])
# Ensure that there are logs for the creation and auto-approval.
kind = LogEntryKind.get(LogEntryKind.name == 'service_key_create')
self.assertEquals(existing_log_count + 1, model.log.LogEntry.select().where(LogEntry.kind == kind).count())
kind = LogEntryKind.get(LogEntryKind.name == 'service_key_approve')
self.assertEquals(existing_log_count + 1, model.log.LogEntry.select().where(LogEntry.kind == kind).count())
self.assertIsNotNone(json['approval'])
self.assertEquals('ServiceKeyApprovalType.SUPERUSER', json['approval']['approval_type'])
self.assertEquals(ADMIN_ACCESS_USER, json['approval']['approver']['username'])
self.assertEquals('whazzup!?', json['approval']['notes'])
class TestSuperUserManagement(ApiTestCase):

View file

@ -1,4 +1,4 @@
from multiprocessing.sharedctypes import Value, Array
from multiprocessing.sharedctypes import Array
from util.validation import MAX_LENGTH
class SuperUserManager(object):