Skip to content

Commit fd5b2de

Browse files
authored
feat(admin): quarantine all projects for user (#18479)
1 parent a0c1f1e commit fd5b2de

File tree

7 files changed

+372
-1
lines changed

7 files changed

+372
-1
lines changed

tests/unit/accounts/test_models.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -159,6 +159,7 @@ def test_acl(self, db_session):
159159
"Allow",
160160
"group:admins",
161161
(
162+
Permissions.AdminProjectsWrite,
162163
Permissions.AdminUsersRead,
163164
Permissions.AdminUsersWrite,
164165
Permissions.AdminUsersEmailWrite,

tests/unit/admin/test_routes.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,20 @@ def test_includeme():
147147
factory="warehouse.accounts.models:UserFactory",
148148
traverse="/{username}",
149149
),
150+
pretend.call(
151+
"admin.user.quarantine_projects",
152+
"/admin/users/{username}/quarantine_projects/",
153+
domain=warehouse,
154+
factory="warehouse.accounts.models:UserFactory",
155+
traverse="/{username}",
156+
),
157+
pretend.call(
158+
"admin.user.clear_quarantine_projects",
159+
"/admin/users/{username}/clear_quarantine_projects/",
160+
domain=warehouse,
161+
factory="warehouse.accounts.models:UserFactory",
162+
traverse="/{username}",
163+
),
150164
pretend.call(
151165
"admin.macaroon.decode_token", "/admin/token/decode", domain=warehouse
152166
),

tests/unit/admin/views/test_users.py

Lines changed: 210 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1590,3 +1590,213 @@ def test_user_email_delete_not_found(self, db_request):
15901590
assert db_request.session.flash.calls == [
15911591
pretend.call("Email not found", queue="error")
15921592
]
1593+
1594+
1595+
class TestUserQuarantineProjects:
1596+
def test_quarantines_user_projects(self, db_request):
1597+
user = UserFactory.create()
1598+
project1 = ProjectFactory.create()
1599+
project2 = ProjectFactory.create()
1600+
RoleFactory(project=project1, user=user, role_name="Owner")
1601+
RoleFactory(project=project2, user=user, role_name="Maintainer")
1602+
1603+
db_request.matchdict["username"] = str(user.username)
1604+
db_request.params = {"username": user.username}
1605+
db_request.route_path = pretend.call_recorder(lambda *a, **kw: "/foobar")
1606+
db_request.session = pretend.stub(
1607+
flash=pretend.call_recorder(lambda *a, **kw: None)
1608+
)
1609+
db_request.user = UserFactory.create()
1610+
1611+
result = views.user_quarantine_projects(user, db_request)
1612+
1613+
assert isinstance(result, HTTPSeeOther)
1614+
assert result.headers["Location"] == "/foobar"
1615+
assert db_request.session.flash.calls == [
1616+
pretend.call(
1617+
f"Quarantined 2 project(s) for user {user.username!r}",
1618+
queue="success",
1619+
)
1620+
]
1621+
assert project1.lifecycle_status == "quarantine-enter"
1622+
assert project2.lifecycle_status == "quarantine-enter"
1623+
1624+
def test_quarantines_user_projects_skips_already_quarantined(self, db_request):
1625+
user = UserFactory.create()
1626+
project1 = ProjectFactory.create(lifecycle_status="quarantine-enter")
1627+
project2 = ProjectFactory.create()
1628+
RoleFactory(project=project1, user=user, role_name="Owner")
1629+
RoleFactory(project=project2, user=user, role_name="Maintainer")
1630+
1631+
db_request.matchdict["username"] = str(user.username)
1632+
db_request.params = {"username": user.username}
1633+
db_request.route_path = pretend.call_recorder(lambda *a, **kw: "/foobar")
1634+
db_request.session = pretend.stub(
1635+
flash=pretend.call_recorder(lambda *a, **kw: None)
1636+
)
1637+
db_request.user = UserFactory.create()
1638+
1639+
result = views.user_quarantine_projects(user, db_request)
1640+
1641+
assert isinstance(result, HTTPSeeOther)
1642+
assert result.headers["Location"] == "/foobar"
1643+
assert db_request.session.flash.calls == [
1644+
pretend.call(
1645+
f"Quarantined 1 project(s) for user {user.username!r}",
1646+
queue="success",
1647+
)
1648+
]
1649+
assert project1.lifecycle_status == "quarantine-enter"
1650+
assert project2.lifecycle_status == "quarantine-enter"
1651+
1652+
def test_quarantines_user_projects_no_projects_to_quarantine(self, db_request):
1653+
user = UserFactory.create()
1654+
project1 = ProjectFactory.create(lifecycle_status="quarantine-enter")
1655+
project2 = ProjectFactory.create(lifecycle_status="quarantine-enter")
1656+
RoleFactory(project=project1, user=user, role_name="Owner")
1657+
RoleFactory(project=project2, user=user, role_name="Maintainer")
1658+
1659+
db_request.matchdict["username"] = str(user.username)
1660+
db_request.params = {"username": user.username}
1661+
db_request.route_path = pretend.call_recorder(lambda *a, **kw: "/foobar")
1662+
db_request.session = pretend.stub(
1663+
flash=pretend.call_recorder(lambda *a, **kw: None)
1664+
)
1665+
db_request.user = UserFactory.create()
1666+
1667+
result = views.user_quarantine_projects(user, db_request)
1668+
1669+
assert isinstance(result, HTTPSeeOther)
1670+
assert result.headers["Location"] == "/foobar"
1671+
assert db_request.session.flash.calls == [
1672+
pretend.call(
1673+
f"No projects needed quarantining for user {user.username!r}",
1674+
queue="info",
1675+
)
1676+
]
1677+
1678+
def test_quarantine_user_projects_bad_confirm(self, db_request):
1679+
user = UserFactory.create()
1680+
project = ProjectFactory.create()
1681+
RoleFactory(project=project, user=user, role_name="Owner")
1682+
1683+
db_request.matchdict["username"] = str(user.username)
1684+
db_request.params = {"username": "wrong"}
1685+
db_request.route_path = pretend.call_recorder(lambda a, **k: "/foobar")
1686+
db_request.session = pretend.stub(
1687+
flash=pretend.call_recorder(lambda *a, **kw: None)
1688+
)
1689+
1690+
result = views.user_quarantine_projects(user, db_request)
1691+
1692+
assert isinstance(result, HTTPSeeOther)
1693+
assert result.headers["Location"] == "/foobar"
1694+
assert db_request.session.flash.calls == [
1695+
pretend.call("Wrong confirmation input", queue="error")
1696+
]
1697+
assert project.lifecycle_status is None
1698+
1699+
1700+
class TestUserClearQuarantineProjects:
1701+
def test_clears_quarantine_user_projects(self, db_request):
1702+
user = UserFactory.create()
1703+
project1 = ProjectFactory.create(lifecycle_status="quarantine-enter")
1704+
project2 = ProjectFactory.create(lifecycle_status="quarantine-enter")
1705+
RoleFactory(project=project1, user=user, role_name="Owner")
1706+
RoleFactory(project=project2, user=user, role_name="Maintain")
1707+
1708+
db_request.matchdict["username"] = str(user.username)
1709+
db_request.params = {"username": user.username}
1710+
db_request.route_path = pretend.call_recorder(lambda *a, **kw: "/foobar")
1711+
db_request.session = pretend.stub(
1712+
flash=pretend.call_recorder(lambda *a, **kw: None)
1713+
)
1714+
db_request.user = UserFactory.create()
1715+
1716+
result = views.user_clear_quarantine_projects(user, db_request)
1717+
1718+
assert isinstance(result, HTTPSeeOther)
1719+
assert result.headers["Location"] == "/foobar"
1720+
assert db_request.session.flash.calls == [
1721+
pretend.call(
1722+
f"Cleared quarantine for 2 project(s) for {user.username!r}",
1723+
queue="success",
1724+
)
1725+
]
1726+
assert project1.lifecycle_status == "quarantine-exit"
1727+
assert project2.lifecycle_status == "quarantine-exit"
1728+
1729+
def test_clears_quarantine_user_projects_skips_non_quarantined(self, db_request):
1730+
user = UserFactory.create()
1731+
project1 = ProjectFactory.create() # Not quarantined
1732+
project2 = ProjectFactory.create(lifecycle_status="quarantine-enter")
1733+
RoleFactory(project=project1, user=user, role_name="Owner")
1734+
RoleFactory(project=project2, user=user, role_name="Maintainer")
1735+
1736+
db_request.matchdict["username"] = str(user.username)
1737+
db_request.params = {"username": user.username}
1738+
db_request.route_path = pretend.call_recorder(lambda *a, **kw: "/foobar")
1739+
db_request.session = pretend.stub(
1740+
flash=pretend.call_recorder(lambda *a, **kw: None)
1741+
)
1742+
db_request.user = UserFactory.create()
1743+
1744+
result = views.user_clear_quarantine_projects(user, db_request)
1745+
1746+
assert isinstance(result, HTTPSeeOther)
1747+
assert result.headers["Location"] == "/foobar"
1748+
assert db_request.session.flash.calls == [
1749+
pretend.call(
1750+
f"Cleared quarantine for 1 project(s) for {user.username!r}",
1751+
queue="success",
1752+
)
1753+
]
1754+
assert project1.lifecycle_status is None
1755+
assert project2.lifecycle_status == "quarantine-exit"
1756+
1757+
def test_clears_quarantine_user_projects_no_quarantined_projects(self, db_request):
1758+
user = UserFactory.create()
1759+
project1 = ProjectFactory.create()
1760+
project2 = ProjectFactory.create()
1761+
RoleFactory(project=project1, user=user, role_name="Owner")
1762+
RoleFactory(project=project2, user=user, role_name="Maintainer")
1763+
1764+
db_request.matchdict["username"] = str(user.username)
1765+
db_request.params = {"username": user.username}
1766+
db_request.route_path = pretend.call_recorder(lambda *a, **kw: "/foobar")
1767+
db_request.session = pretend.stub(
1768+
flash=pretend.call_recorder(lambda *a, **kw: None)
1769+
)
1770+
db_request.user = UserFactory.create()
1771+
1772+
result = views.user_clear_quarantine_projects(user, db_request)
1773+
1774+
assert isinstance(result, HTTPSeeOther)
1775+
assert result.headers["Location"] == "/foobar"
1776+
assert db_request.session.flash.calls == [
1777+
pretend.call(
1778+
f"No quarantined projects found for user {user.username!r}",
1779+
queue="info",
1780+
)
1781+
]
1782+
1783+
def test_clear_quarantine_user_projects_bad_confirm(self, db_request):
1784+
user = UserFactory.create()
1785+
project = ProjectFactory.create(lifecycle_status="quarantine-enter")
1786+
RoleFactory(project=project, user=user, role_name="Owner")
1787+
1788+
db_request.matchdict["username"] = str(user.username)
1789+
db_request.params = {"username": "wrong"}
1790+
db_request.route_path = pretend.call_recorder(lambda a, **k: "/foobar")
1791+
db_request.session = pretend.stub(
1792+
flash=pretend.call_recorder(lambda *a, **kw: None)
1793+
)
1794+
1795+
result = views.user_clear_quarantine_projects(user, db_request)
1796+
1797+
assert isinstance(result, HTTPSeeOther)
1798+
assert result.headers["Location"] == "/foobar"
1799+
assert db_request.session.flash.calls == [
1800+
pretend.call("Wrong confirmation input", queue="error")
1801+
]
1802+
assert project.lifecycle_status == "quarantine-enter"

warehouse/accounts/models.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -294,6 +294,7 @@ def __acl__(self):
294294
Allow,
295295
"group:admins",
296296
(
297+
Permissions.AdminProjectsWrite,
297298
Permissions.AdminUsersRead,
298299
Permissions.AdminUsersWrite,
299300
Permissions.AdminUsersEmailWrite,

warehouse/admin/routes.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,20 @@ def includeme(config):
145145
factory="warehouse.accounts.models:UserFactory",
146146
traverse="/{username}",
147147
)
148+
config.add_route(
149+
"admin.user.quarantine_projects",
150+
"/admin/users/{username}/quarantine_projects/",
151+
domain=warehouse,
152+
factory="warehouse.accounts.models:UserFactory",
153+
traverse="/{username}",
154+
)
155+
config.add_route(
156+
"admin.user.clear_quarantine_projects",
157+
"/admin/users/{username}/clear_quarantine_projects/",
158+
domain=warehouse,
159+
factory="warehouse.accounts.models:UserFactory",
160+
traverse="/{username}",
161+
)
148162

149163
# Macaroon related Admin pages
150164
config.add_route(

warehouse/admin/templates/admin/users/detail.html

Lines changed: 61 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
{% import "admin/utils/pagination.html" as pagination %}
66

7+
{% set perms_admin_projects_write = request.has_permission(Permissions.AdminProjectsWrite) %}
78
{% set perms_admin_users_write = request.has_permission(Permissions.AdminUsersWrite) %}
89
{% set perms_admin_users_email_write = request.has_permission(Permissions.AdminUsersEmailWrite) %}
910
{% set perms_admin_users_account_recovery_write = request.has_permission(Permissions.AdminUsersAccountRecoveryWrite) %}
@@ -127,6 +128,12 @@ <h2 class="card-title">Actions</h2>
127128
<i class="fa-solid fa-toilet-paper"></i> Wipe 2FA and recovery codes
128129
</button>
129130
{% endif %}
131+
<button type="button" class="btn btn-block btn-warning" data-toggle="modal" data-target="#quarantineAllProjectsModal" {{ "disabled" if not perms_admin_projects_write }}>
132+
<i class="icon fa fa-lock"></i> Quarantine all projects & freeze user
133+
</button>
134+
<button type="button" class="btn btn-block btn-success" data-toggle="modal" data-target="#clearQuarantineAllProjectsModal" {{ "disabled" if not perms_admin_projects_write }}>
135+
<i class="icon fa fa-unlock"></i> Clear all projects from quarantine
136+
</button>
130137
<div class="modal fade" id="freezeModal" tabindex="-1" role="dialog">
131138
<form method="POST" action="{{ request.route_path('admin.user.freeze', username=user.username) }}">
132139
<input name="csrf_token" type="hidden" value="{{ request.session.get_csrf_token() }}">
@@ -172,7 +179,7 @@ <h4 class="modal-title" id="nukeModalLabel">Freeze user {{ user.username }} and
172179
</div>
173180
</form>
174181
</div>
175-
<div class="modal fade" id="nukeModal" tabindex="-1" role="dialog">
182+
<div class="modal fade" id="nukeModal" tabindex="-1" role="dialog">
176183
<form method="POST" action="{{ request.route_path('admin.user.delete', username=user.username) }}">
177184
<input name="csrf_token" type="hidden" value="{{ request.session.get_csrf_token() }}">
178185
<div class="modal-dialog" role="document">
@@ -280,6 +287,59 @@ <h4 class="modal-title" id="wipeFactorsModalLabel">Wipe second/recovery factors
280287
</div>
281288
</form>
282289
</div>
290+
<!-- Quarantine All Projects Modal -->
291+
<div class="modal fade" id="quarantineAllProjectsModal" tabindex="-1" role="dialog">
292+
<form method="POST" action="{{ request.route_path('admin.user.quarantine_projects', username=user.username) }}">
293+
<input name="csrf_token" type="hidden" value="{{ request.session.get_csrf_token() }}">
294+
<div class="modal-dialog" role="document">
295+
<div class="modal-content">
296+
<div class="modal-header">
297+
<h4 class="modal-title">Quarantine all projects for {{ user.username }}?</h4>
298+
<button type="button" class="close" data-dismiss="modal">
299+
<span>&times;</span>
300+
</button>
301+
</div>
302+
<div class="modal-body">
303+
<p>This will quarantine all projects owned by this user, removing them from public APIs until cleared or removed.</p>
304+
<p>This action is reversible.</p>
305+
<hr>
306+
<p>Enter username '{{ user.username }}' to confirm:</p>
307+
<input type="text" name="username" placeholder="{{ user.username }}" autocomplete="off" autocorrect="off" autocapitalize="off">
308+
</div>
309+
<div class="modal-footer">
310+
<button type="button" class="btn btn-secondary" data-dismiss="modal">Close</button>
311+
<button type="submit" class="btn btn-warning">Quarantine all projects</button>
312+
</div>
313+
</div>
314+
</div>
315+
</form>
316+
</div>
317+
<!-- Clear Quarantine All Projects Modal -->
318+
<div class="modal fade" id="clearQuarantineAllProjectsModal" tabindex="-1" role="dialog">
319+
<form method="POST" action="{{ request.route_path('admin.user.clear_quarantine_projects', username=user.username) }}">
320+
<input name="csrf_token" type="hidden" value="{{ request.session.get_csrf_token() }}">
321+
<div class="modal-dialog" role="document">
322+
<div class="modal-content">
323+
<div class="modal-header">
324+
<h4 class="modal-title">Clear quarantine for all projects for {{ user.username }}?</h4>
325+
<button type="button" class="close" data-dismiss="modal">
326+
<span>&times;</span>
327+
</button>
328+
</div>
329+
<div class="modal-body">
330+
<p>This will clear quarantine for all quarantined projects owned by this user, making them publicly available again.</p>
331+
<hr>
332+
<p>Enter username '{{ user.username }}' to confirm:</p>
333+
<input type="text" name="username" placeholder="{{ user.username }}" autocomplete="off" autocorrect="off" autocapitalize="off">
334+
</div>
335+
<div class="modal-footer">
336+
<button type="button" class="btn btn-secondary" data-dismiss="modal">Close</button>
337+
<button type="submit" class="btn btn-success">Clear quarantine for all projects</button>
338+
</div>
339+
</div>
340+
</div>
341+
</form>
342+
</div>
283343
</div>
284344
</div>
285345
</div> <!-- .card -->

0 commit comments

Comments
 (0)