diff --git a/tests/unit/accounts/test_views.py b/tests/unit/accounts/test_views.py index d5303bd0f4d5..e12a4c9db615 100644 --- a/tests/unit/accounts/test_views.py +++ b/tests/unit/accounts/test_views.py @@ -31,7 +31,7 @@ TokenMissing, TooManyFailedLogins, ) -from warehouse.admin.flags import AdminFlag +from warehouse.admin.flags import AdminFlag, AdminFlagValue from ...common.db.accounts import EmailFactory, UserFactory @@ -904,7 +904,9 @@ def test_register_redirect(self, db_request, monkeypatch): def test_register_fails_with_admin_flag_set(self, db_request): # This flag was already set via migration, just need to enable it - flag = db_request.db.query(AdminFlag).get("disallow-new-user-registration") + flag = db_request.db.query(AdminFlag).get( + AdminFlagValue.DISALLOW_NEW_USER_REGISTRATION.value + ) flag.enabled = True db_request.method = "POST" diff --git a/tests/unit/admin/test_flags.py b/tests/unit/admin/test_flags.py index cd5ec4397491..e03f64f72c31 100644 --- a/tests/unit/admin/test_flags.py +++ b/tests/unit/admin/test_flags.py @@ -10,14 +10,21 @@ # See the License for the specific language governing permissions and # limitations under the License. +import enum + from ...common.db.admin import AdminFlagFactory +class TestAdminFlagValues(enum.Enum): + NOT_A_REAL_FLAG = "not-a-real-flag" + THIS_FLAG_IS_ENABLED = "this-flag-is-enabled" + + class TestAdminFlag: def test_default(self, db_request): - assert not db_request.flags.enabled("not-a-real-flag") + assert not db_request.flags.enabled(TestAdminFlagValues.NOT_A_REAL_FLAG) def test_enabled(self, db_request): AdminFlagFactory(id="this-flag-is-enabled") - assert db_request.flags.enabled("this-flag-is-enabled") + assert db_request.flags.enabled(TestAdminFlagValues.THIS_FLAG_IS_ENABLED) diff --git a/tests/unit/forklift/test_legacy.py b/tests/unit/forklift/test_legacy.py index ad3a28a33366..1c86b541bb91 100644 --- a/tests/unit/forklift/test_legacy.py +++ b/tests/unit/forklift/test_legacy.py @@ -31,7 +31,7 @@ from wtforms.form import Form from wtforms.validators import ValidationError -from warehouse.admin.flags import AdminFlag +from warehouse.admin.flags import AdminFlag, AdminFlagValue from warehouse.admin.squats import Squat from warehouse.classifiers.models import Classifier from warehouse.forklift import legacy @@ -753,6 +753,25 @@ def test_is_duplicate_false(self, pyramid_config, db_request): class TestFileUpload: + def test_fails_disallow_new_upload(self, pyramid_config, pyramid_request): + pyramid_config.testing_securitypolicy(userid=1) + pyramid_request.flags = pretend.stub( + enabled=lambda value: value == AdminFlagValue.DISALLOW_NEW_UPLOAD + ) + pyramid_request.help_url = pretend.call_recorder(lambda **kw: "/the/help/url/") + pyramid_request.user = pretend.stub(primary_email=pretend.stub(verified=True)) + + with pytest.raises(HTTPForbidden) as excinfo: + legacy.file_upload(pyramid_request) + + resp = excinfo.value + + assert resp.status_code == 403 + assert resp.status == ( + "403 New uploads are temporarily disabled. " + "See /the/help/url/ for details" + ) + @pytest.mark.parametrize("version", ["2", "3", "-1", "0", "dog", "cat"]) def test_fails_invalid_version(self, pyramid_config, pyramid_request, version): pyramid_config.testing_securitypolicy(userid=1) @@ -1118,7 +1137,9 @@ def test_fails_with_stdlib_names(self, pyramid_config, db_request, name): def test_fails_with_admin_flag_set(self, pyramid_config, db_request): admin_flag = ( db_request.db.query(AdminFlag) - .filter(AdminFlag.id == "disallow-new-project-registration") + .filter( + AdminFlag.id == AdminFlagValue.DISALLOW_NEW_PROJECT_REGISTRATION.value + ) .first() ) admin_flag.enabled = True diff --git a/tests/unit/manage/test_views.py b/tests/unit/manage/test_views.py index 8b9b6be70853..e0f72622944d 100644 --- a/tests/unit/manage/test_views.py +++ b/tests/unit/manage/test_views.py @@ -27,6 +27,7 @@ import warehouse.utils.otp as otp from warehouse.accounts.interfaces import IPasswordBreachedService, IUserService +from warehouse.admin.flags import AdminFlagValue from warehouse.macaroons.interfaces import IMacaroonService from warehouse.manage import views from warehouse.packaging.models import ( @@ -2014,6 +2015,7 @@ def test_delete_project_no_confirm(self): project = pretend.stub(normalized_name="foo") request = pretend.stub( POST={}, + flags=pretend.stub(enabled=pretend.call_recorder(lambda *a: False)), session=pretend.stub(flash=pretend.call_recorder(lambda *a, **kw: None)), route_path=lambda *a, **kw: "/foo/bar/", ) @@ -2023,6 +2025,9 @@ def test_delete_project_no_confirm(self): assert exc.value.status_code == 303 assert exc.value.headers["Location"] == "/foo/bar/" + assert request.flags.enabled.calls == [ + pretend.call(AdminFlagValue.DISALLOW_DELETION) + ] assert request.session.flash.calls == [ pretend.call("Confirm the request", queue="error") ] @@ -2031,6 +2036,7 @@ def test_delete_project_wrong_confirm(self): project = pretend.stub(normalized_name="foo") request = pretend.stub( POST={"confirm_project_name": "bar"}, + flags=pretend.stub(enabled=pretend.call_recorder(lambda *a: False)), session=pretend.stub(flash=pretend.call_recorder(lambda *a, **kw: None)), route_path=lambda *a, **kw: "/foo/bar/", ) @@ -2040,6 +2046,9 @@ def test_delete_project_wrong_confirm(self): assert exc.value.status_code == 303 assert exc.value.headers["Location"] == "/foo/bar/" + assert request.flags.enabled.calls == [ + pretend.call(AdminFlagValue.DISALLOW_DELETION) + ] assert request.session.flash.calls == [ pretend.call( "Could not delete project - 'bar' is not the same as 'foo'", @@ -2047,6 +2056,36 @@ def test_delete_project_wrong_confirm(self): ) ] + def test_delete_project_disallow_deletion(self): + project = pretend.stub(name="foo", normalized_name="foo") + request = pretend.stub( + flags=pretend.stub(enabled=pretend.call_recorder(lambda *a: True)), + route_path=pretend.call_recorder(lambda *a, **kw: "/the-redirect"), + session=pretend.stub(flash=pretend.call_recorder(lambda *a, **kw: None)), + ) + + result = views.delete_project(project, request) + assert isinstance(result, HTTPSeeOther) + assert result.headers["Location"] == "/the-redirect" + + assert request.flags.enabled.calls == [ + pretend.call(AdminFlagValue.DISALLOW_DELETION) + ] + + assert request.session.flash.calls == [ + pretend.call( + ( + "Project deletion temporarily disabled. " + "See https://pypi.org/help#admin-intervention for details." + ), + queue="error", + ) + ] + + assert request.route_path.calls == [ + pretend.call("manage.project.settings", project_name="foo") + ] + def test_delete_project(self, db_request): project = ProjectFactory.create(name="foo") @@ -2159,6 +2198,7 @@ def test_manage_project_releases(self, db_request): filename=f"foobar-{release.version}.tar.gz", packagetype="sdist", ) + db_request.flags = pretend.stub(enabled=pretend.call_recorder(lambda *a: False)) assert views.manage_project_releases(project, db_request) == { "project": project, @@ -2182,6 +2222,48 @@ def test_manage_project_release(self): "files": files, } + def test_delete_project_release_disallow_deletion(self, monkeypatch): + release = pretend.stub( + version="1.2.3", + canonical_version="1.2.3", + project=pretend.stub( + name="foobar", record_event=pretend.call_recorder(lambda *a, **kw: None) + ), + ) + request = pretend.stub( + flags=pretend.stub(enabled=pretend.call_recorder(lambda *a: True)), + method="POST", + route_path=pretend.call_recorder(lambda *a, **kw: "/the-redirect"), + session=pretend.stub(flash=pretend.call_recorder(lambda *a, **kw: None)), + ) + view = views.ManageProjectRelease(release, request) + + result = view.delete_project_release() + assert isinstance(result, HTTPSeeOther) + assert result.headers["Location"] == "/the-redirect" + + assert request.flags.enabled.calls == [ + pretend.call(AdminFlagValue.DISALLOW_DELETION) + ] + + assert request.session.flash.calls == [ + pretend.call( + ( + "Project deletion temporarily disabled. " + "See https://pypi.org/help#admin-intervention for details." + ), + queue="error", + ) + ] + + assert request.route_path.calls == [ + pretend.call( + "manage.project.release", + project_name=release.project.name, + version=release.version, + ) + ] + def test_delete_project_release(self, monkeypatch): release = pretend.stub( version="1.2.3", @@ -2197,6 +2279,7 @@ def test_delete_project_release(self, monkeypatch): delete=pretend.call_recorder(lambda a: None), add=pretend.call_recorder(lambda a: None), ), + flags=pretend.stub(enabled=pretend.call_recorder(lambda *a: False)), route_path=pretend.call_recorder(lambda *a, **kw: "/the-redirect"), session=pretend.stub(flash=pretend.call_recorder(lambda *a, **kw: None)), user=pretend.stub(username=pretend.stub()), @@ -2215,6 +2298,9 @@ def test_delete_project_release(self, monkeypatch): assert request.db.delete.calls == [pretend.call(release)] assert request.db.add.calls == [pretend.call(journal_obj)] + assert request.flags.enabled.calls == [ + pretend.call(AdminFlagValue.DISALLOW_DELETION) + ] assert journal_cls.calls == [ pretend.call( name=release.project.name, @@ -2247,6 +2333,7 @@ def test_delete_project_release_no_confirm(self): POST={"confirm_version": ""}, method="POST", db=pretend.stub(delete=pretend.call_recorder(lambda a: None)), + flags=pretend.stub(enabled=pretend.call_recorder(lambda *a: False)), route_path=pretend.call_recorder(lambda *a, **kw: "/the-redirect"), session=pretend.stub(flash=pretend.call_recorder(lambda *a, **kw: None)), ) @@ -2261,6 +2348,9 @@ def test_delete_project_release_no_confirm(self): assert request.session.flash.calls == [ pretend.call("Confirm the request", queue="error") ] + assert request.flags.enabled.calls == [ + pretend.call(AdminFlagValue.DISALLOW_DELETION) + ] assert request.route_path.calls == [ pretend.call( "manage.project.release", @@ -2275,6 +2365,7 @@ def test_delete_project_release_bad_confirm(self): POST={"confirm_version": "invalid"}, method="POST", db=pretend.stub(delete=pretend.call_recorder(lambda a: None)), + flags=pretend.stub(enabled=pretend.call_recorder(lambda *a: False)), route_path=pretend.call_recorder(lambda *a, **kw: "/the-redirect"), session=pretend.stub(flash=pretend.call_recorder(lambda *a, **kw: None)), ) @@ -2301,6 +2392,42 @@ def test_delete_project_release_bad_confirm(self): ) ] + def test_delete_project_release_file_disallow_deletion(self): + release = pretend.stub(version="1.2.3", project=pretend.stub(name="foobar")) + request = pretend.stub( + method="POST", + flags=pretend.stub(enabled=pretend.call_recorder(lambda *a: True)), + route_path=pretend.call_recorder(lambda *a, **kw: "/the-redirect"), + session=pretend.stub(flash=pretend.call_recorder(lambda *a, **kw: None)), + ) + view = views.ManageProjectRelease(release, request) + + result = view.delete_project_release_file() + + assert isinstance(result, HTTPSeeOther) + assert result.headers["Location"] == "/the-redirect" + + assert request.flags.enabled.calls == [ + pretend.call(AdminFlagValue.DISALLOW_DELETION) + ] + + assert request.session.flash.calls == [ + pretend.call( + ( + "Project deletion temporarily disabled. " + "See https://pypi.org/help#admin-intervention for details." + ), + queue="error", + ) + ] + assert request.route_path.calls == [ + pretend.call( + "manage.project.release", + project_name=release.project.name, + version=release.version, + ) + ] + def test_delete_project_release_file(self, db_request): user = UserFactory.create() @@ -2359,6 +2486,7 @@ def test_delete_project_release_file_no_confirm(self): POST={"confirm_project_name": ""}, method="POST", db=pretend.stub(delete=pretend.call_recorder(lambda a: None)), + flags=pretend.stub(enabled=pretend.call_recorder(lambda *a: False)), route_path=pretend.call_recorder(lambda *a, **kw: "/the-redirect"), session=pretend.stub(flash=pretend.call_recorder(lambda *a, **kw: None)), ) @@ -2370,6 +2498,9 @@ def test_delete_project_release_file_no_confirm(self): assert result.headers["Location"] == "/the-redirect" assert request.db.delete.calls == [] + assert request.flags.enabled.calls == [ + pretend.call(AdminFlagValue.DISALLOW_DELETION) + ] assert request.session.flash.calls == [ pretend.call("Confirm the request", queue="error") ] @@ -2396,6 +2527,7 @@ def no_result_found(): filter=lambda *a: pretend.stub(one=no_result_found) ), ) + db_request.flags = pretend.stub(enabled=pretend.call_recorder(lambda *a: False)) db_request.route_path = pretend.call_recorder(lambda *a, **kw: "/the-redirect") db_request.session = pretend.stub( flash=pretend.call_recorder(lambda *a, **kw: None) @@ -2409,6 +2541,9 @@ def no_result_found(): assert result.headers["Location"] == "/the-redirect" assert db_request.db.delete.calls == [] + assert db_request.flags.enabled.calls == [ + pretend.call(AdminFlagValue.DISALLOW_DELETION) + ] assert db_request.session.flash.calls == [ pretend.call("Could not find file", queue="error") ] diff --git a/tests/unit/test_db.py b/tests/unit/test_db.py index 29d6ce6f597c..3c3e247a7041 100644 --- a/tests/unit/test_db.py +++ b/tests/unit/test_db.py @@ -24,6 +24,7 @@ from sqlalchemy.exc import OperationalError from warehouse import db +from warehouse.admin.flags import AdminFlagValue from warehouse.db import ( DEFAULT_ISOLATION, DatabaseNotAvailable, @@ -273,7 +274,7 @@ def test_create_session_read_only_mode( ) assert _create_session(request) is session_obj - assert get.calls == [pretend.call("read-only")] + assert get.calls == [pretend.call(AdminFlagValue.READ_ONLY.value)] assert request.tm.doom.calls == doom_calls diff --git a/warehouse/accounts/views.py b/warehouse/accounts/views.py index 3aee6560070e..cac14b225ee8 100644 --- a/warehouse/accounts/views.py +++ b/warehouse/accounts/views.py @@ -44,6 +44,7 @@ TooManyFailedLogins, ) from warehouse.accounts.models import Email, User +from warehouse.admin.flags import AdminFlagValue from warehouse.cache.origin import origin_cache from warehouse.email import send_email_verification_email, send_password_reset_email from warehouse.i18n import localize as _ @@ -418,7 +419,7 @@ def register(request, _form_class=RegistrationForm): if request.method == "POST" and request.POST.get("confirm_form"): return HTTPSeeOther(request.route_path("index")) - if request.flags.enabled("disallow-new-user-registration"): + if request.flags.enabled(AdminFlagValue.DISALLOW_NEW_USER_REGISTRATION): request.session.flash( _( "new-user-registration-temp-disabled", diff --git a/warehouse/admin/flags.py b/warehouse/admin/flags.py index 267f06a08a7e..356a0ba4247a 100644 --- a/warehouse/admin/flags.py +++ b/warehouse/admin/flags.py @@ -10,11 +10,21 @@ # See the License for the specific language governing permissions and # limitations under the License. +import enum + from sqlalchemy import Boolean, Column, Text, sql from warehouse import db +class AdminFlagValue(enum.Enum): + DISALLOW_DELETION = "disallow-deletion" + DISALLOW_NEW_PROJECT_REGISTRATION = "disallow-new-project-registration" + DISALLOW_NEW_UPLOAD = "disallow-new-upload" + DISALLOW_NEW_USER_REGISTRATION = "disallow-new-user-registration" + READ_ONLY = "read-only" + + class AdminFlag(db.ModelBase): __tablename__ = "admin_flags" @@ -36,8 +46,8 @@ def notifications(self): .all() ) - def enabled(self, flag_name): - flag = self.request.db.query(AdminFlag).get(flag_name) + def enabled(self, flag_member): + flag = self.request.db.query(AdminFlag).get(flag_member.value) return flag.enabled if flag else False diff --git a/warehouse/db.py b/warehouse/db.py index 89de8b7daaaa..1ef65c266b96 100644 --- a/warehouse/db.py +++ b/warehouse/db.py @@ -208,9 +208,9 @@ def cleanup(request): connection.close() # Check if we're in read-only mode - from warehouse.admin.flags import AdminFlag + from warehouse.admin.flags import AdminFlag, AdminFlagValue - flag = session.query(AdminFlag).get("read-only") + flag = session.query(AdminFlag).get(AdminFlagValue.READ_ONLY.value) if flag and flag.enabled and not request.user.is_superuser: request.tm.doom() diff --git a/warehouse/forklift/legacy.py b/warehouse/forklift/legacy.py index fd5f59c80bc4..a009a30ac555 100644 --- a/warehouse/forklift/legacy.py +++ b/warehouse/forklift/legacy.py @@ -39,6 +39,7 @@ from sqlalchemy.orm.exc import MultipleResultsFound, NoResultFound from warehouse import forms +from warehouse.admin.flags import AdminFlagValue from warehouse.admin.squats import Squat from warehouse.classifiers.models import Classifier from warehouse.metrics import IMetricsService @@ -728,11 +729,20 @@ def validate_no_deprecated_classifiers(form, field): ) def file_upload(request): # If we're in read-only mode, let upload clients know - if request.flags.enabled("read-only"): + if request.flags.enabled(AdminFlagValue.READ_ONLY): raise _exc_with_message( HTTPForbidden, "Read-only mode: Uploads are temporarily disabled" ) + if request.flags.enabled(AdminFlagValue.DISALLOW_NEW_UPLOAD): + raise _exc_with_message( + HTTPForbidden, + "New uploads are temporarily disabled. " + "See {projecthelp} for details".format( + projecthelp=request.help_url(_anchor="admin-intervention") + ), + ) + # Log an attempt to upload metrics = request.find_service(IMetricsService, context=None) metrics.increment("warehouse.upload.attempt") @@ -850,7 +860,7 @@ def file_upload(request): # Check for AdminFlag set by a PyPI Administrator disabling new project # registration, reasons for this include Spammers, security # vulnerabilities, or just wanting to be lazy and not worry ;) - if request.flags.enabled("disallow-new-project-registration"): + if request.flags.enabled(AdminFlagValue.DISALLOW_NEW_PROJECT_REGISTRATION): raise _exc_with_message( HTTPForbidden, ( diff --git a/warehouse/manage/views.py b/warehouse/manage/views.py index 17cde653b362..329d5f648d7d 100644 --- a/warehouse/manage/views.py +++ b/warehouse/manage/views.py @@ -30,6 +30,7 @@ from warehouse.accounts.interfaces import IPasswordBreachedService, IUserService from warehouse.accounts.models import Email, User from warehouse.accounts.views import logout +from warehouse.admin.flags import AdminFlagValue from warehouse.email import ( send_account_deletion_email, send_added_as_collaborator_email, @@ -783,6 +784,18 @@ def manage_project_settings(project, request): permission="manage:project", ) def delete_project(project, request): + if request.flags.enabled(AdminFlagValue.DISALLOW_DELETION): + request.session.flash( + ( + "Project deletion temporarily disabled. " + "See https://pypi.org/help#admin-intervention for details." + ), + queue="error", + ) + return HTTPSeeOther( + request.route_path("manage.project.settings", project_name=project.name) + ) + confirm_project(project, request, fail_route="manage.project.settings") remove_project(project, request) @@ -872,6 +885,22 @@ def manage_project_release(self): @view_config(request_method="POST", request_param=["confirm_version"]) def delete_project_release(self): + if self.request.flags.enabled(AdminFlagValue.DISALLOW_DELETION): + self.request.session.flash( + ( + "Project deletion temporarily disabled. " + "See https://pypi.org/help#admin-intervention for details." + ), + queue="error", + ) + return HTTPSeeOther( + self.request.route_path( + "manage.project.release", + project_name=self.release.project.name, + version=self.release.version, + ) + ) + version = self.request.POST.get("confirm_version") if not version: self.request.session.flash("Confirm the request", queue="error") @@ -942,6 +971,13 @@ def _error(message): ) ) + if self.request.flags.enabled(AdminFlagValue.DISALLOW_DELETION): + message = ( + "Project deletion temporarily disabled. " + "See https://pypi.org/help#admin-intervention for details." + ) + return _error(message) + project_name = self.request.POST.get("confirm_project_name") if not project_name: diff --git a/warehouse/migrations/versions/8650482fb903_add_disallow_deletion_adminflag.py b/warehouse/migrations/versions/8650482fb903_add_disallow_deletion_adminflag.py new file mode 100644 index 000000000000..9f33fb0e872e --- /dev/null +++ b/warehouse/migrations/versions/8650482fb903_add_disallow_deletion_adminflag.py @@ -0,0 +1,41 @@ +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +""" +Add disallow-deletion AdminFlag + +Revision ID: 8650482fb903 +Revises: 34b18e18775c +Create Date: 2019-08-23 13:29:17.110252 +""" + +from alembic import op + +revision = "8650482fb903" +down_revision = "34b18e18775c" + + +def upgrade(): + op.execute( + """ + INSERT INTO admin_flags(id, description, enabled, notify) + VALUES ( + 'disallow-deletion', + 'Disallow ALL project and release deletions', + FALSE, + FALSE + ) + """ + ) + + +def downgrade(): + op.execute("DELETE FROM admin_flags WHERE id = 'disallow-deletion'") diff --git a/warehouse/migrations/versions/ee4c59b2ef3a_add_disallow_new_upload_adminflag.py b/warehouse/migrations/versions/ee4c59b2ef3a_add_disallow_new_upload_adminflag.py new file mode 100644 index 000000000000..97e4d0bc7603 --- /dev/null +++ b/warehouse/migrations/versions/ee4c59b2ef3a_add_disallow_new_upload_adminflag.py @@ -0,0 +1,41 @@ +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +""" +Add disallow-new-upload AdminFlag + +Revision ID: ee4c59b2ef3a +Revises: 8650482fb903 +Create Date: 2019-08-23 22:34:29.180163 +""" + +from alembic import op + +revision = "ee4c59b2ef3a" +down_revision = "8650482fb903" + + +def upgrade(): + op.execute( + """ + INSERT INTO admin_flags(id, description, enabled, notify) + VALUES ( + 'disallow-new-upload', + 'Disallow ALL new uploads', + FALSE, + FALSE + ) + """ + ) + + +def downgrade(): + op.execute("DELETE FROM admin_flags WHERE id = 'disallow-new-upload'")