Skip to content

Admin feature: Nuke user #2977

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 8 commits into from
Feb 19, 2018
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
82 changes: 77 additions & 5 deletions tests/unit/admin/test_routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,15 +16,87 @@


def test_includeme():
warehouse = "w.local"
config = pretend.stub(
add_route=pretend.call_recorder(lambda *a, **k: None),
get_settings=lambda: {"warehouse.domain": "w.local"},
get_settings=lambda: {"warehouse.domain": warehouse},
)

includeme(config)

config.add_route.calls == [
pretend.call("admin.dashboard", "/admin/", domain="w.local"),
pretend.call("admin.login", "/admin/login/", domain="w.local"),
pretend.call("admin.logout", "/admin/logout/", domain="w.local"),
assert config.add_route.calls == [
pretend.call("admin.dashboard", "/admin/", domain=warehouse),
pretend.call("admin.login", "/admin/login/", domain=warehouse),
pretend.call("admin.logout", "/admin/logout/", domain=warehouse),
pretend.call("admin.user.list", "/admin/users/", domain=warehouse),
pretend.call(
"admin.user.detail",
"/admin/users/{user_id}/",
domain=warehouse,
),
pretend.call(
"admin.user.delete",
"/admin/users/{user_id}/delete/",
domain=warehouse,
),
pretend.call(
"admin.project.list",
"/admin/projects/",
domain=warehouse,
),
pretend.call(
"admin.project.detail",
"/admin/projects/{project_name}/",
factory="warehouse.packaging.models:ProjectFactory",
traverse="/{project_name}/",
domain=warehouse,
),
pretend.call(
"admin.project.releases",
"/admin/projects/{project_name}/releases/",
factory="warehouse.packaging.models:ProjectFactory",
traverse="/{project_name}",
domain=warehouse,
),
pretend.call(
"admin.project.journals",
"/admin/projects/{project_name}/journals/",
factory="warehouse.packaging.models:ProjectFactory",
traverse="/{project_name}",
domain=warehouse,
),
pretend.call(
"admin.project.set_upload_limit",
"/admin/projects/{project_name}/set_upload_limit/",
factory="warehouse.packaging.models:ProjectFactory",
traverse="/{project_name}",
domain=warehouse,
),
pretend.call(
"admin.project.delete",
"/admin/projects/{project_name}/delete/",
factory="warehouse.packaging.models:ProjectFactory",
traverse="/{project_name}",
domain=warehouse,
),
pretend.call(
"admin.journals.list",
"/admin/journals/",
domain=warehouse,
),
pretend.call(
"admin.blacklist.list",
"/admin/blacklist/",
domain=warehouse,
),
pretend.call(
"admin.blacklist.add",
"/admin/blacklist/add/",
domain=warehouse,
),
pretend.call(
"admin.blacklist.remove",
"/admin/blacklist/remove/",
domain=warehouse,
),
]
55 changes: 54 additions & 1 deletion tests/unit/admin/views/test_users.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@

from warehouse.admin.views import users as views

from ....common.db.accounts import UserFactory, EmailFactory
from ....common.db.accounts import User, UserFactory, EmailFactory
from ....common.db.packaging import ProjectFactory, RoleFactory


Expand Down Expand Up @@ -169,3 +169,56 @@ def test_updates_user(self, db_request):
assert resp.status_code == 303
assert resp.location == "/admin/users/{}/".format(user.id)
assert user.name == "Jane Doe"


class TestUserDelete:

def test_deletes_user(self, db_request, monkeypatch):
user = UserFactory.create()
project = ProjectFactory.create()
RoleFactory(project=project, user=user, role_name='Owner')

db_request.matchdict['user_id'] = str(user.id)
db_request.params = {'username': user.username}
db_request.route_path = pretend.call_recorder(lambda a: '/foobar')
db_request.user = UserFactory.create()
db_request.remote_addr = '10.10.10.10'

remove_project = pretend.call_recorder(lambda *a, **kw: None)
monkeypatch.setattr(views, 'remove_project', remove_project)

result = views.user_delete(db_request)

db_request.db.flush()

assert not db_request.db.query(User).get(user.id)
assert remove_project.calls == [
pretend.call(project, db_request, flash=False),
]
assert db_request.route_path.calls == [pretend.call('admin.user.list')]
assert result.status_code == 303
assert result.location == '/foobar'

def test_deletes_user_bad_confirm(self, db_request, monkeypatch):
user = UserFactory.create()
project = ProjectFactory.create()
RoleFactory(project=project, user=user, role_name='Owner')

db_request.matchdict['user_id'] = str(user.id)
db_request.params = {'username': 'wrong'}
db_request.route_path = pretend.call_recorder(lambda a, **k: '/foobar')

remove_project = pretend.call_recorder(lambda *a, **kw: None)
monkeypatch.setattr(views, 'remove_project', remove_project)

result = views.user_delete(db_request)

db_request.db.flush()

assert db_request.db.query(User).get(user.id)
assert remove_project.calls == []
assert db_request.route_path.calls == [
pretend.call('admin.user.detail', user_id=user.id),
]
assert result.status_code == 303
assert result.location == '/foobar'
25 changes: 16 additions & 9 deletions tests/unit/utils/test_project.py
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,11 @@ def test_confirm_incorrect_input():
]


def test_remove_project(db_request):
@pytest.mark.parametrize(
'flash',
[True, False]
)
def test_remove_project(db_request, flash):
user = UserFactory.create()
project = ProjectFactory.create(name="foo")
release = ReleaseFactory.create(project=project)
Expand All @@ -99,14 +103,17 @@ def test_remove_project(db_request):
db_request.remote_addr = "192.168.1.1"
db_request.session = stub(flash=call_recorder(lambda *a, **kw: stub()))

remove_project(project, db_request)

assert db_request.session.flash.calls == [
call(
"Successfully deleted the project 'foo'.",
queue="success"
),
]
remove_project(project, db_request, flash=flash)

if flash:
assert db_request.session.flash.calls == [
call(
"Successfully deleted the project 'foo'.",
queue="success"
),
]
else:
assert db_request.session.flash.calls == []

assert not (db_request.db.query(Role)
.filter(Role.project == project).count())
Expand Down
5 changes: 5 additions & 0 deletions warehouse/admin/routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,11 @@ def includeme(config):
"/admin/users/{user_id}/",
domain=warehouse,
)
config.add_route(
"admin.user.delete",
"/admin/users/{user_id}/delete/",
domain=warehouse,
)

# Project related Admin pages
config.add_route(
Expand Down
53 changes: 49 additions & 4 deletions warehouse/admin/templates/admin/users/detail.html
Original file line number Diff line number Diff line change
Expand Up @@ -67,17 +67,62 @@ <h2 class="box-title">Actions</h2>
</div>
<div class="box-body">
<ul class="list-unstyled">
{% if not user.is_active %}
<li><a href="#TODO">Resend Confirmation Email</a></li>
{% endif %}
<li><a href="#TODO">Reset Password</a></li>
<li>
<button type="button" class="btn btn-danger" data-toggle="modal" data-target="#nukeModal">
<i class="icon fa fa-bomb"></i> Nuke user
</button>
<div class="modal fade" id="nukeModal" tabindex="-1" role="dialog">
<form method="POST" action="{{ request.route_path('admin.user.delete', user_id=user.id) }}">
<input name="csrf_token" type="hidden" value="{{ request.session.get_csrf_token() }}">
<div class="modal-dialog" role="document">
<div class="modal-content">
<div class="modal-header">
<h4 class="modal-title" id="exampleModalLabel">Nuke user {{ user.username }}?</h4>
<button type="button" class="close" data-dismiss="modal">
<span>&times;</span>
</button>
</div>
<div class="modal-body">
<p>
This will permanently destroy the user and cannot be undone.
</p>
<p>
This will also delete the following projects and their respective releases:
</p>
<ul>
{% for project in user.projects %}
<li>
<a href="{{ request.route_path('admin.project.detail', project_name=project.normalized_name) }}">
{{ project.name }}
</a> ({{ project.releases|length }} releases)
</li>
{% endfor %}
</ul>
<hr>
<p>
Type the username '{{ user.username }}' to confirm:
</p>
<input type="text" name="username" placeholder="{{ user.username }}">
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-dismiss="modal">Close</button>
<button type="submit" class="btn btn-danger">Nuke 'em</button>
</div>
</div>
</form>
</div>
</div>
</li>
</ul>
</div>
</div>
</div>

<div class="col-md-9">
<div class="box">
<div class="box-header with-border">
<h3 class="box-title">Edit User</h3>
</div>
<div class="box-body">
<form class="form-horizontal" method="POST" action="{{ request.current_route_path() }}">
<input name="csrf_token" type="hidden" value="{{ request.session.get_csrf_token() }}">
Expand Down
38 changes: 37 additions & 1 deletion warehouse/admin/views/users.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,9 @@

from warehouse import forms
from warehouse.accounts.models import User, Email
from warehouse.packaging.models import Role
from warehouse.packaging.models import JournalEntry, Role
from warehouse.utils.paginate import paginate_url_factory
from warehouse.utils.project import remove_project


@view_config(
Expand Down Expand Up @@ -125,3 +126,38 @@ def user_detail(request):
return HTTPSeeOther(location=request.current_route_path())

return {"user": user, "form": form, "roles": roles}


@view_config(
route_name='admin.user.delete',
require_methods=['POST'],
permission='admin',
uses_session=True,
require_csrf=True,
)
def user_delete(request):
user = request.db.query(User).get(request.matchdict['user_id'])

if user.username != request.params.get('username'):
print(user.username)
print(request.params.get('username'))
request.session.flash(f'Wrong confirmation input.', queue='error')
return HTTPSeeOther(
request.route_path('admin.user.detail', user_id=user.id)
)

# Delete projects one by one so they are purged from the cache
for project in user.projects:
remove_project(project, request, flash=False)

request.db.delete(user)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this will implicitly cascade to their associated emails?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

also probably want a JournalEntry

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, this will delete emails and roles for that user.

I thought about adding a JournalEntry here, but it seems like they're project-specific? E.g., we don't make them when a user account is created, just when stuff changes with a specific project.

Copy link
Member

@ewdurbin ewdurbin Feb 19, 2018

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fair, but I would like some record of this feature being used. the journal is freeform. we should just write something to note that an admin used this feature.

Copy link
Member

@ewdurbin ewdurbin Feb 19, 2018

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

JournalEntry(
    action='purge_user',
    name=f'user:{user.username}',
    submitted_by=request.user,
    submitted_from=request.remote_addr,
)

would work and can't collide with project names.

request.db.add(
JournalEntry(
name=f'user:{user.username}',
action=f'nuke user',
submitted_by=request.user,
submitted_from=request.remote_addr,
)
)
request.session.flash(f'Nuked user {user.username!r}.', queue='success')
return HTTPSeeOther(request.route_path('admin.user.list'))
14 changes: 9 additions & 5 deletions warehouse/utils/project.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ def confirm_project(project, request, fail_route):
)


def remove_project(project, request):
def remove_project(project, request, flash=True):
# TODO: We don't actually delete files from the data store. We should add
# some kind of garbage collection at some point.

Expand Down Expand Up @@ -74,7 +74,11 @@ def remove_project(project, request):
# Finally, delete the project
request.db.delete(project)

request.session.flash(
f"Successfully deleted the project {project.name!r}.",
queue="success",
)
# Flush so we can repeat this multiple times if necessary
request.db.flush()

if flash:
request.session.flash(
f"Successfully deleted the project {project.name!r}.",
queue="success",
)