diff --git a/dev/environment b/dev/environment index 04c22c4d14c4..dcffbd5621b1 100644 --- a/dev/environment +++ b/dev/environment @@ -49,3 +49,4 @@ GITHUB_TOKEN_SCANNING_META_API_URL="http://notgithub:8000/meta/public_keys/token TWOFACTORREQUIREMENT_ENABLED=true TWOFACTORMANDATE_AVAILABLE=true TWOFACTORMANDATE_ENABLED=true +OIDC_ENABLED=true diff --git a/tests/unit/email/test_init.py b/tests/unit/email/test_init.py index deb1cb0a7740..5ae27303bb8e 100644 --- a/tests/unit/email/test_init.py +++ b/tests/unit/email/test_init.py @@ -3575,3 +3575,96 @@ def test_recovery_code_emails( }, ) ] + + +class TestOIDCProviderEmails: + @pytest.mark.parametrize( + "fn, template_name", + [ + (email.send_oidc_provider_added_email, "oidc-provider-added"), + (email.send_oidc_provider_removed_email, "oidc-provider-removed"), + ], + ) + def test_oidc_provider_emails( + self, pyramid_request, pyramid_config, monkeypatch, fn, template_name + ): + stub_user = pretend.stub( + id="id", + username="username", + name="", + email="email@example.com", + primary_email=pretend.stub(email="email@example.com", verified=True), + ) + subject_renderer = pyramid_config.testing_add_renderer( + f"email/{ template_name }/subject.txt" + ) + subject_renderer.string_response = "Email Subject" + body_renderer = pyramid_config.testing_add_renderer( + f"email/{ template_name }/body.txt" + ) + body_renderer.string_response = "Email Body" + html_renderer = pyramid_config.testing_add_renderer( + f"email/{ template_name }/body.html" + ) + html_renderer.string_response = "Email HTML Body" + + send_email = pretend.stub( + delay=pretend.call_recorder(lambda *args, **kwargs: None) + ) + pyramid_request.task = pretend.call_recorder(lambda *args, **kwargs: send_email) + monkeypatch.setattr(email, "send_email", send_email) + + pyramid_request.db = pretend.stub( + query=lambda a: pretend.stub( + filter=lambda *a: pretend.stub( + one=lambda: pretend.stub(user_id=stub_user.id) + ) + ), + ) + pyramid_request.user = stub_user + pyramid_request.registry.settings = {"mail.sender": "noreply@example.com"} + + project_name = "test_project" + fakeprovider = pretend.stub(provider_name="fakeprovider") + # NOTE: Can't set __str__ using pretend.stub() + monkeypatch.setattr( + fakeprovider.__class__, "__str__", lambda s: "fakespecifier" + ) + + result = fn( + pyramid_request, stub_user, project_name=project_name, provider=fakeprovider + ) + + assert result == { + "username": stub_user.username, + "project_name": project_name, + "provider_name": "fakeprovider", + "provider_spec": "fakespecifier", + } + subject_renderer.assert_() + body_renderer.assert_(username=stub_user.username, project_name=project_name) + html_renderer.assert_(username=stub_user.username, project_name=project_name) + assert pyramid_request.task.calls == [pretend.call(send_email)] + assert send_email.delay.calls == [ + pretend.call( + f"{stub_user.username} <{stub_user.email}>", + { + "subject": "Email Subject", + "body_text": "Email Body", + "body_html": ( + "\n\n" + "

Email HTML Body

\n\n" + ), + }, + { + "tag": "account:email:sent", + "user_id": stub_user.id, + "additional": { + "from_": "noreply@example.com", + "to": stub_user.email, + "subject": "Email Subject", + "redact_ip": False, + }, + }, + ) + ] diff --git a/tests/unit/manage/test_init.py b/tests/unit/manage/test_init.py index b621712e9993..8ce56cb5bb21 100644 --- a/tests/unit/manage/test_init.py +++ b/tests/unit/manage/test_init.py @@ -94,13 +94,37 @@ def view(context, request): assert request.session.needs_reauthentication.calls == needs_reauth_calls -def test_includeme(): +def test_includeme(monkeypatch): + settings = { + "warehouse.manage.oidc.user_registration_ratelimit_string": "10 per day", + "warehouse.manage.oidc.ip_registration_ratelimit_string": "100 per day", + } + config = pretend.stub( add_view_deriver=pretend.call_recorder(lambda f, over, under: None), + register_service_factory=pretend.call_recorder(lambda s, i, **kw: None), + registry=pretend.stub( + settings=pretend.stub(get=pretend.call_recorder(lambda k: settings.get(k))) + ), ) + rate_limit_class = pretend.call_recorder(lambda s: s) + rate_limit_iface = pretend.stub() + monkeypatch.setattr(manage, "RateLimit", rate_limit_class) + monkeypatch.setattr(manage, "IRateLimiter", rate_limit_iface) + manage.includeme(config) assert config.add_view_deriver.calls == [ pretend.call(manage.reauth_view, over="rendered_view", under="decorated_view") ] + assert config.register_service_factory.calls == [ + pretend.call( + "10 per day", rate_limit_iface, name="user_oidc.provider.register" + ), + pretend.call("100 per day", rate_limit_iface, name="ip_oidc.provider.register"), + ] + assert config.registry.settings.get.calls == [ + pretend.call("warehouse.manage.oidc.user_registration_ratelimit_string"), + pretend.call("warehouse.manage.oidc.ip_registration_ratelimit_string"), + ] diff --git a/tests/unit/manage/test_views.py b/tests/unit/manage/test_views.py index 8f0798715f5c..4b9483c25a01 100644 --- a/tests/unit/manage/test_views.py +++ b/tests/unit/manage/test_views.py @@ -18,7 +18,12 @@ import pytest from paginate_sqlalchemy import SqlalchemyOrmPage as SQLAlchemyORMPage -from pyramid.httpexceptions import HTTPBadRequest, HTTPNotFound, HTTPSeeOther +from pyramid.httpexceptions import ( + HTTPBadRequest, + HTTPNotFound, + HTTPSeeOther, + HTTPTooManyRequests, +) from pyramid.response import Response from sqlalchemy.orm import joinedload from sqlalchemy.orm.exc import NoResultFound @@ -37,6 +42,8 @@ from warehouse.forklift.legacy import MAX_FILESIZE, MAX_PROJECT_SIZE from warehouse.macaroons.interfaces import IMacaroonService from warehouse.manage import views +from warehouse.metrics.interfaces import IMetricsService +from warehouse.oidc.interfaces import TooManyOIDCRegistrations from warehouse.packaging.models import ( File, JournalEntry, @@ -46,6 +53,7 @@ RoleInvitation, User, ) +from warehouse.rate_limiting import IRateLimiter from warehouse.utils.paginate import paginate_url_factory from warehouse.utils.project import remove_documentation @@ -4682,3 +4690,805 @@ def test_raises_404_with_out_of_range_page(self, db_request): with pytest.raises(HTTPNotFound): assert views.manage_project_journal(project, db_request) + + +class TestManageOIDCProviderViews: + def test_initializes(self): + metrics = pretend.stub() + project = pretend.stub() + request = pretend.stub( + registry=pretend.stub(settings={"warehouse.oidc.enabled": True}), + find_service=pretend.call_recorder(lambda *a, **kw: metrics), + ) + view = views.ManageOIDCProviderViews(project, request) + + assert view.project is project + assert view.request is request + assert view.oidc_enabled + assert view.metrics is metrics + + assert view.request.find_service.calls == [ + pretend.call(IMetricsService, context=None) + ] + + @pytest.mark.parametrize( + "ip_exceeded, user_exceeded", + [ + (False, False), + (False, True), + (True, False), + ], + ) + def test_ratelimiting(self, ip_exceeded, user_exceeded): + project = pretend.stub() + + metrics = pretend.stub() + user_rate_limiter = pretend.stub( + hit=pretend.call_recorder(lambda *a, **kw: None), + test=pretend.call_recorder(lambda uid: not user_exceeded), + resets_in=pretend.call_recorder(lambda uid: pretend.stub()), + ) + ip_rate_limiter = pretend.stub( + hit=pretend.call_recorder(lambda *a, **kw: None), + test=pretend.call_recorder(lambda ip: not ip_exceeded), + resets_in=pretend.call_recorder(lambda uid: pretend.stub()), + ) + + def find_service(iface, name=None, context=None): + if iface is IMetricsService: + return metrics + + if name == "user_oidc.provider.register": + return user_rate_limiter + else: + return ip_rate_limiter + + request = pretend.stub( + registry=pretend.stub(settings={"warehouse.oidc.enabled": True}), + find_service=pretend.call_recorder(find_service), + user=pretend.stub(id=pretend.stub()), + remote_addr=pretend.stub(), + ) + + view = views.ManageOIDCProviderViews(project, request) + + assert view._ratelimiters == { + "user.oidc": user_rate_limiter, + "ip.oidc": ip_rate_limiter, + } + assert request.find_service.calls == [ + pretend.call(IMetricsService, context=None), + pretend.call(IRateLimiter, name="user_oidc.provider.register"), + pretend.call(IRateLimiter, name="ip_oidc.provider.register"), + ] + + view._hit_ratelimits() + + assert user_rate_limiter.hit.calls == [ + pretend.call(request.user.id), + ] + assert ip_rate_limiter.hit.calls == [pretend.call(request.remote_addr)] + + if user_exceeded or ip_exceeded: + with pytest.raises(TooManyOIDCRegistrations): + view._check_ratelimits() + else: + view._check_ratelimits() + + def test_manage_project_oidc_providers(self, monkeypatch): + project = pretend.stub() + request = pretend.stub( + registry=pretend.stub( + settings={ + "warehouse.oidc.enabled": True, + "github.token": "fake-api-token", + }, + ), + find_service=lambda *a, **kw: None, + flags=pretend.stub(enabled=pretend.call_recorder(lambda f: False)), + POST=pretend.stub(), + ) + + github_provider_form_obj = pretend.stub() + github_provider_form_cls = pretend.call_recorder( + lambda *a, **kw: github_provider_form_obj + ) + monkeypatch.setattr(views, "GitHubProviderForm", github_provider_form_cls) + + view = views.ManageOIDCProviderViews(project, request) + assert view.manage_project_oidc_providers() == { + "oidc_enabled": True, + "project": project, + "github_provider_form": github_provider_form_obj, + } + + assert request.flags.enabled.calls == [ + pretend.call(AdminFlagValue.DISALLOW_OIDC) + ] + assert github_provider_form_cls.calls == [ + pretend.call(request.POST, api_token="fake-api-token") + ] + + def test_manage_project_oidc_providers_admin_disabled(self, monkeypatch): + project = pretend.stub() + request = pretend.stub( + registry=pretend.stub( + settings={ + "warehouse.oidc.enabled": True, + "github.token": "fake-api-token", + }, + ), + find_service=lambda *a, **kw: None, + flags=pretend.stub(enabled=pretend.call_recorder(lambda f: True)), + session=pretend.stub(flash=pretend.call_recorder(lambda *a, **kw: None)), + POST=pretend.stub(), + ) + + view = views.ManageOIDCProviderViews(project, request) + github_provider_form_obj = pretend.stub() + github_provider_form_cls = pretend.call_recorder( + lambda *a, **kw: github_provider_form_obj + ) + monkeypatch.setattr(views, "GitHubProviderForm", github_provider_form_cls) + + view = views.ManageOIDCProviderViews(project, request) + assert view.manage_project_oidc_providers() == { + "oidc_enabled": True, + "project": project, + "github_provider_form": github_provider_form_obj, + } + + assert request.flags.enabled.calls == [ + pretend.call(AdminFlagValue.DISALLOW_OIDC) + ] + assert request.session.flash.calls == [ + pretend.call( + ( + "OpenID Connect is temporarily disabled. " + "See https://pypi.org/help#admin-intervention for details." + ), + queue="error", + ) + ] + assert github_provider_form_cls.calls == [ + pretend.call(request.POST, api_token="fake-api-token") + ] + + def test_manage_project_oidc_providers_oidc_not_enabled(self): + project = pretend.stub() + request = pretend.stub( + registry=pretend.stub(settings={"warehouse.oidc.enabled": False}), + find_service=lambda *a, **kw: None, + ) + + view = views.ManageOIDCProviderViews(project, request) + + with pytest.raises(HTTPNotFound): + view.manage_project_oidc_providers() + + def test_add_github_oidc_provider_preexisting(self, monkeypatch): + provider = pretend.stub( + id="fakeid", + provider_name="GitHub", + repository_name="fakerepo", + owner="fakeowner", + owner_id="1234", + workflow_filename="fakeworkflow.yml", + ) + # NOTE: Can't set __str__ using pretend.stub() + monkeypatch.setattr(provider.__class__, "__str__", lambda s: "fakespecifier") + + project = pretend.stub( + name="fakeproject", + oidc_providers=[], + record_event=pretend.call_recorder(lambda *a, **kw: None), + users=[], + ) + + metrics = pretend.stub(increment=pretend.call_recorder(lambda *a, **kw: None)) + + request = pretend.stub( + registry=pretend.stub( + settings={ + "warehouse.oidc.enabled": True, + "github.token": "fake-api-token", + } + ), + find_service=lambda *a, **kw: metrics, + flags=pretend.stub(enabled=pretend.call_recorder(lambda f: False)), + session=pretend.stub(flash=pretend.call_recorder(lambda *a, **kw: None)), + POST=pretend.stub(), + db=pretend.stub( + query=lambda *a: pretend.stub( + filter=lambda *a: pretend.stub(one_or_none=lambda: provider) + ), + add=pretend.call_recorder(lambda o: None), + ), + remote_addr="0.0.0.0", + ) + + github_provider_form_obj = pretend.stub( + validate=pretend.call_recorder(lambda: True), + repository=pretend.stub(data=provider.repository_name), + normalized_owner=provider.owner, + workflow_filename=pretend.stub(data=provider.workflow_filename), + ) + github_provider_form_cls = pretend.call_recorder( + lambda *a, **kw: github_provider_form_obj + ) + monkeypatch.setattr(views, "GitHubProviderForm", github_provider_form_cls) + + view = views.ManageOIDCProviderViews(project, request) + monkeypatch.setattr( + view, "_hit_ratelimits", pretend.call_recorder(lambda: None) + ) + monkeypatch.setattr( + view, "_check_ratelimits", pretend.call_recorder(lambda: None) + ) + + assert view.add_github_oidc_provider() == { + "oidc_enabled": True, + "project": project, + "github_provider_form": github_provider_form_obj, + } + assert view.metrics.increment.calls == [ + pretend.call( + "warehouse.oidc.add_provider.attempt", tags=["provider:GitHub"] + ), + pretend.call("warehouse.oidc.add_provider.ok", tags=["provider:GitHub"]), + ] + assert project.record_event.calls == [ + pretend.call( + tag="project:oidc:provider-added", + ip_address=request.remote_addr, + additional={ + "provider": "GitHub", + "id": "fakeid", + "specifier": "fakespecifier", + }, + ) + ] + assert request.session.flash.calls == [ + pretend.call( + "Added fakespecifier to fakeproject", + queue="success", + ) + ] + assert request.db.add.calls == [] + assert github_provider_form_obj.validate.calls == [pretend.call()] + assert view._hit_ratelimits.calls == [pretend.call()] + assert view._check_ratelimits.calls == [pretend.call()] + assert project.oidc_providers == [provider] + + def test_add_github_oidc_provider_created(self, monkeypatch): + fakeusers = [pretend.stub(), pretend.stub(), pretend.stub()] + project = pretend.stub( + name="fakeproject", + oidc_providers=[], + record_event=pretend.call_recorder(lambda *a, **kw: None), + users=fakeusers, + ) + + metrics = pretend.stub(increment=pretend.call_recorder(lambda *a, **kw: None)) + + request = pretend.stub( + registry=pretend.stub( + settings={ + "warehouse.oidc.enabled": True, + "github.token": "fake-api-token", + } + ), + find_service=lambda *a, **kw: metrics, + flags=pretend.stub(enabled=pretend.call_recorder(lambda f: False)), + session=pretend.stub(flash=pretend.call_recorder(lambda *a, **kw: None)), + POST=pretend.stub(), + db=pretend.stub( + query=lambda *a: pretend.stub( + filter=lambda *a: pretend.stub(one_or_none=lambda: None) + ), + add=pretend.call_recorder(lambda o: setattr(o, "id", "fakeid")), + ), + remote_addr="0.0.0.0", + ) + + github_provider_form_obj = pretend.stub( + validate=pretend.call_recorder(lambda: True), + repository=pretend.stub(data="fakerepo"), + normalized_owner="fakeowner", + owner_id="1234", + workflow_filename=pretend.stub(data="fakeworkflow.yml"), + ) + github_provider_form_cls = pretend.call_recorder( + lambda *a, **kw: github_provider_form_obj + ) + monkeypatch.setattr(views, "GitHubProviderForm", github_provider_form_cls) + monkeypatch.setattr( + views, + "send_oidc_provider_added_email", + pretend.call_recorder(lambda *a, **kw: None), + ) + + view = views.ManageOIDCProviderViews(project, request) + monkeypatch.setattr( + view, "_hit_ratelimits", pretend.call_recorder(lambda: None) + ) + monkeypatch.setattr( + view, "_check_ratelimits", pretend.call_recorder(lambda: None) + ) + + assert view.add_github_oidc_provider() == { + "oidc_enabled": True, + "project": project, + "github_provider_form": github_provider_form_obj, + } + assert view.metrics.increment.calls == [ + pretend.call( + "warehouse.oidc.add_provider.attempt", tags=["provider:GitHub"] + ), + pretend.call("warehouse.oidc.add_provider.ok", tags=["provider:GitHub"]), + ] + assert project.record_event.calls == [ + pretend.call( + tag="project:oidc:provider-added", + ip_address=request.remote_addr, + additional={ + "provider": "GitHub", + "id": "fakeid", + "specifier": "fakeworkflow.yml @ fakeowner/fakerepo", + }, + ) + ] + assert request.session.flash.calls == [ + pretend.call( + "Added fakeworkflow.yml @ fakeowner/fakerepo to fakeproject", + queue="success", + ) + ] + assert request.db.add.calls == [pretend.call(project.oidc_providers[0])] + assert github_provider_form_obj.validate.calls == [pretend.call()] + assert views.send_oidc_provider_added_email.calls == [ + pretend.call( + request, + fakeuser, + project_name="fakeproject", + provider=project.oidc_providers[0], + ) + for fakeuser in fakeusers + ] + assert view._hit_ratelimits.calls == [pretend.call()] + assert view._check_ratelimits.calls == [pretend.call()] + assert len(project.oidc_providers) == 1 + + def test_add_github_oidc_provider_already_registered_with_project( + self, monkeypatch + ): + provider = pretend.stub( + id="fakeid", + provider_name="GitHub", + repository_name="fakerepo", + owner="fakeowner", + owner_id="1234", + workflow_filename="fakeworkflow.yml", + ) + # NOTE: Can't set __str__ using pretend.stub() + monkeypatch.setattr(provider.__class__, "__str__", lambda s: "fakespecifier") + + metrics = pretend.stub(increment=pretend.call_recorder(lambda *a, **kw: None)) + + project = pretend.stub( + name="fakeproject", + oidc_providers=[provider], + record_event=pretend.call_recorder(lambda *a, **kw: None), + ) + + request = pretend.stub( + registry=pretend.stub( + settings={ + "warehouse.oidc.enabled": True, + "github.token": "fake-api-token", + } + ), + find_service=lambda *a, **kw: metrics, + flags=pretend.stub(enabled=pretend.call_recorder(lambda f: False)), + session=pretend.stub(flash=pretend.call_recorder(lambda *a, **kw: None)), + POST=pretend.stub(), + db=pretend.stub( + query=lambda *a: pretend.stub( + filter=lambda *a: pretend.stub(one_or_none=lambda: provider) + ), + ), + ) + + github_provider_form_obj = pretend.stub( + validate=pretend.call_recorder(lambda: True), + repository=pretend.stub(data=provider.repository_name), + normalized_owner=provider.owner, + workflow_filename=pretend.stub(data=provider.workflow_filename), + ) + github_provider_form_cls = pretend.call_recorder( + lambda *a, **kw: github_provider_form_obj + ) + monkeypatch.setattr(views, "GitHubProviderForm", github_provider_form_cls) + + view = views.ManageOIDCProviderViews(project, request) + monkeypatch.setattr( + view, "_hit_ratelimits", pretend.call_recorder(lambda: None) + ) + monkeypatch.setattr( + view, "_check_ratelimits", pretend.call_recorder(lambda: None) + ) + + assert view.add_github_oidc_provider() == { + "oidc_enabled": True, + "project": project, + "github_provider_form": github_provider_form_obj, + } + assert view.metrics.increment.calls == [ + pretend.call( + "warehouse.oidc.add_provider.attempt", tags=["provider:GitHub"] + ), + ] + assert project.record_event.calls == [] + assert request.session.flash.calls == [ + pretend.call( + "fakespecifier is already registered with fakeproject", + queue="error", + ) + ] + + def test_add_github_oidc_provider_ratelimited(self, monkeypatch): + project = pretend.stub() + + metrics = pretend.stub(increment=pretend.call_recorder(lambda *a, **kw: None)) + + request = pretend.stub( + registry=pretend.stub( + settings={ + "warehouse.oidc.enabled": True, + } + ), + find_service=lambda *a, **kw: metrics, + flags=pretend.stub(enabled=pretend.call_recorder(lambda f: False)), + _=lambda s: s, + ) + + view = views.ManageOIDCProviderViews(project, request) + monkeypatch.setattr( + view, + "_check_ratelimits", + pretend.call_recorder( + pretend.raiser( + TooManyOIDCRegistrations( + resets_in=pretend.stub(total_seconds=lambda: 60) + ) + ) + ), + ) + + assert view.add_github_oidc_provider().__class__ == HTTPTooManyRequests + assert view.metrics.increment.calls == [ + pretend.call( + "warehouse.oidc.add_provider.attempt", tags=["provider:GitHub"] + ), + pretend.call( + "warehouse.oidc.add_provider.ratelimited", tags=["provider:GitHub"] + ), + ] + + def test_add_github_oidc_provider_oidc_not_enabled(self): + project = pretend.stub() + request = pretend.stub( + registry=pretend.stub(settings={"warehouse.oidc.enabled": False}), + find_service=lambda *a, **kw: None, + ) + + view = views.ManageOIDCProviderViews(project, request) + + with pytest.raises(HTTPNotFound): + view.add_github_oidc_provider() + + def test_add_github_oidc_provider_admin_disabled(self, monkeypatch): + project = pretend.stub() + metrics = pretend.stub(increment=pretend.call_recorder(lambda *a, **kw: None)) + request = pretend.stub( + registry=pretend.stub(settings={"warehouse.oidc.enabled": True}), + find_service=lambda *a, **kw: metrics, + flags=pretend.stub(enabled=pretend.call_recorder(lambda f: True)), + session=pretend.stub(flash=pretend.call_recorder(lambda *a, **kw: None)), + _=lambda s: s, + ) + + view = views.ManageOIDCProviderViews(project, request) + default_response = {"_": pretend.stub()} + monkeypatch.setattr( + views.ManageOIDCProviderViews, "default_response", default_response + ) + + assert view.add_github_oidc_provider() == default_response + assert view.metrics.increment.calls == [ + pretend.call( + "warehouse.oidc.add_provider.attempt", tags=["provider:GitHub"] + ), + ] + assert request.session.flash.calls == [ + pretend.call( + ( + "OpenID Connect is temporarily disabled. " + "See https://pypi.org/help#admin-intervention for details." + ), + queue="error", + ) + ] + + def test_add_github_oidc_provider_invalid_form(self, monkeypatch): + project = pretend.stub() + metrics = pretend.stub(increment=pretend.call_recorder(lambda *a, **kw: None)) + request = pretend.stub( + registry=pretend.stub(settings={"warehouse.oidc.enabled": True}), + find_service=lambda *a, **kw: metrics, + flags=pretend.stub(enabled=pretend.call_recorder(lambda f: False)), + session=pretend.stub(flash=pretend.call_recorder(lambda *a, **kw: None)), + _=lambda s: s, + ) + + github_provider_form_obj = pretend.stub( + validate=pretend.call_recorder(lambda: False), + ) + github_provider_form_cls = pretend.call_recorder( + lambda *a, **kw: github_provider_form_obj + ) + monkeypatch.setattr(views, "GitHubProviderForm", github_provider_form_cls) + + view = views.ManageOIDCProviderViews(project, request) + default_response = {"github_provider_form": github_provider_form_obj} + monkeypatch.setattr( + views.ManageOIDCProviderViews, "default_response", default_response + ) + monkeypatch.setattr( + view, "_check_ratelimits", pretend.call_recorder(lambda: None) + ) + monkeypatch.setattr( + view, "_hit_ratelimits", pretend.call_recorder(lambda: None) + ) + + assert view.add_github_oidc_provider() == default_response + assert view.metrics.increment.calls == [ + pretend.call( + "warehouse.oidc.add_provider.attempt", tags=["provider:GitHub"] + ), + ] + assert view._hit_ratelimits.calls == [pretend.call()] + assert view._check_ratelimits.calls == [pretend.call()] + assert github_provider_form_obj.validate.calls == [pretend.call()] + + def test_delete_oidc_provider(self, monkeypatch): + provider = pretend.stub( + provider_name="fakeprovider", + id="fakeid", + ) + # NOTE: Can't set __str__ using pretend.stub() + monkeypatch.setattr(provider.__class__, "__str__", lambda s: "fakespecifier") + + fakeusers = [pretend.stub(), pretend.stub(), pretend.stub()] + project = pretend.stub( + oidc_providers=[provider], + name="fakeproject", + record_event=pretend.call_recorder(lambda *a, **kw: None), + users=fakeusers, + ) + metrics = pretend.stub(increment=pretend.call_recorder(lambda *a, **kw: None)) + request = pretend.stub( + registry=pretend.stub(settings={"warehouse.oidc.enabled": True}), + find_service=lambda *a, **kw: metrics, + flags=pretend.stub(enabled=pretend.call_recorder(lambda f: False)), + session=pretend.stub(flash=pretend.call_recorder(lambda *a, **kw: None)), + POST=pretend.stub(), + db=pretend.stub( + query=lambda *a: pretend.stub(get=lambda id: provider), + ), + remote_addr="0.0.0.0", + ) + + delete_provider_form_obj = pretend.stub( + validate=pretend.call_recorder(lambda: True), + provider_id=pretend.stub(data="fakeid"), + ) + delete_provider_form_cls = pretend.call_recorder( + lambda *a, **kw: delete_provider_form_obj + ) + monkeypatch.setattr(views, "DeleteProviderForm", delete_provider_form_cls) + monkeypatch.setattr( + views, + "send_oidc_provider_removed_email", + pretend.call_recorder(lambda *a, **kw: None), + ) + + view = views.ManageOIDCProviderViews(project, request) + default_response = {"_": pretend.stub()} + monkeypatch.setattr( + views.ManageOIDCProviderViews, "default_response", default_response + ) + + assert view.delete_oidc_provider() == default_response + assert provider not in project.oidc_providers + + assert view.metrics.increment.calls == [ + pretend.call( + "warehouse.oidc.delete_provider.attempt", + ), + pretend.call( + "warehouse.oidc.delete_provider.ok", tags=["provider:fakeprovider"] + ), + ] + + assert project.record_event.calls == [ + pretend.call( + tag="project:oidc:provider-removed", + ip_address=request.remote_addr, + additional={ + "provider": "fakeprovider", + "id": "fakeid", + "specifier": "fakespecifier", + }, + ) + ] + + assert request.flags.enabled.calls == [ + pretend.call(AdminFlagValue.DISALLOW_OIDC) + ] + assert request.session.flash.calls == [ + pretend.call("Removed fakespecifier from fakeproject", queue="success") + ] + + assert delete_provider_form_cls.calls == [pretend.call(request.POST)] + assert delete_provider_form_obj.validate.calls == [pretend.call()] + + assert views.send_oidc_provider_removed_email.calls == [ + pretend.call( + request, fakeuser, project_name="fakeproject", provider=provider + ) + for fakeuser in fakeusers + ] + + def test_delete_oidc_provider_invalid_form(self, monkeypatch): + provider = pretend.stub() + project = pretend.stub(oidc_providers=[provider]) + metrics = pretend.stub(increment=pretend.call_recorder(lambda *a, **kw: None)) + request = pretend.stub( + registry=pretend.stub(settings={"warehouse.oidc.enabled": True}), + find_service=lambda *a, **kw: metrics, + flags=pretend.stub(enabled=pretend.call_recorder(lambda f: False)), + POST=pretend.stub(), + ) + + delete_provider_form_obj = pretend.stub( + validate=pretend.call_recorder(lambda: False), + ) + delete_provider_form_cls = pretend.call_recorder( + lambda *a, **kw: delete_provider_form_obj + ) + monkeypatch.setattr(views, "DeleteProviderForm", delete_provider_form_cls) + + view = views.ManageOIDCProviderViews(project, request) + default_response = {"_": pretend.stub()} + monkeypatch.setattr( + views.ManageOIDCProviderViews, "default_response", default_response + ) + + assert view.delete_oidc_provider() == default_response + assert len(project.oidc_providers) == 1 + + assert view.metrics.increment.calls == [ + pretend.call( + "warehouse.oidc.delete_provider.attempt", + ), + ] + + assert delete_provider_form_cls.calls == [pretend.call(request.POST)] + assert delete_provider_form_obj.validate.calls == [pretend.call()] + + @pytest.mark.parametrize( + "other_provider", [None, pretend.stub(id="different-fakeid")] + ) + def test_delete_oidc_provider_not_found(self, monkeypatch, other_provider): + provider = pretend.stub( + provider_name="fakeprovider", + id="fakeid", + ) + # NOTE: Can't set __str__ using pretend.stub() + monkeypatch.setattr(provider.__class__, "__str__", lambda s: "fakespecifier") + + project = pretend.stub( + oidc_providers=[provider], + name="fakeproject", + record_event=pretend.call_recorder(lambda *a, **kw: None), + ) + metrics = pretend.stub(increment=pretend.call_recorder(lambda *a, **kw: None)) + request = pretend.stub( + registry=pretend.stub(settings={"warehouse.oidc.enabled": True}), + find_service=lambda *a, **kw: metrics, + flags=pretend.stub(enabled=pretend.call_recorder(lambda f: False)), + session=pretend.stub(flash=pretend.call_recorder(lambda *a, **kw: None)), + POST=pretend.stub(), + db=pretend.stub( + query=lambda *a: pretend.stub(get=lambda id: other_provider), + ), + remote_addr="0.0.0.0", + ) + + delete_provider_form_obj = pretend.stub( + validate=pretend.call_recorder(lambda: True), + provider_id=pretend.stub(data="different-fakeid"), + ) + delete_provider_form_cls = pretend.call_recorder( + lambda *a, **kw: delete_provider_form_obj + ) + monkeypatch.setattr(views, "DeleteProviderForm", delete_provider_form_cls) + + view = views.ManageOIDCProviderViews(project, request) + default_response = {"_": pretend.stub()} + monkeypatch.setattr( + views.ManageOIDCProviderViews, "default_response", default_response + ) + + assert view.delete_oidc_provider() == default_response + assert provider in project.oidc_providers # not deleted + assert other_provider not in project.oidc_providers + + assert view.metrics.increment.calls == [ + pretend.call( + "warehouse.oidc.delete_provider.attempt", + ), + ] + + assert project.record_event.calls == [] + assert request.session.flash.calls == [ + pretend.call("Invalid publisher for project", queue="error") + ] + + assert delete_provider_form_cls.calls == [pretend.call(request.POST)] + assert delete_provider_form_obj.validate.calls == [pretend.call()] + + def test_delete_oidc_provider_oidc_not_enabled(self): + project = pretend.stub() + request = pretend.stub( + registry=pretend.stub(settings={"warehouse.oidc.enabled": False}), + find_service=lambda *a, **kw: None, + ) + + view = views.ManageOIDCProviderViews(project, request) + + with pytest.raises(HTTPNotFound): + view.delete_oidc_provider() + + def test_delete_oidc_provider_admin_disabled(self, monkeypatch): + project = pretend.stub() + metrics = pretend.stub(increment=pretend.call_recorder(lambda *a, **kw: None)) + request = pretend.stub( + registry=pretend.stub(settings={"warehouse.oidc.enabled": True}), + find_service=lambda *a, **kw: metrics, + flags=pretend.stub(enabled=pretend.call_recorder(lambda f: True)), + session=pretend.stub(flash=pretend.call_recorder(lambda *a, **kw: None)), + ) + + view = views.ManageOIDCProviderViews(project, request) + default_response = {"_": pretend.stub()} + monkeypatch.setattr( + views.ManageOIDCProviderViews, "default_response", default_response + ) + + assert view.delete_oidc_provider() == default_response + assert view.metrics.increment.calls == [ + pretend.call( + "warehouse.oidc.delete_provider.attempt", + ), + ] + assert request.session.flash.calls == [ + pretend.call( + ( + "OpenID Connect is temporarily disabled. " + "See https://pypi.org/help#admin-intervention for details." + ), + queue="error", + ) + ] diff --git a/tests/unit/oidc/test_forms.py b/tests/unit/oidc/test_forms.py new file mode 100644 index 000000000000..8558c9543a73 --- /dev/null +++ b/tests/unit/oidc/test_forms.py @@ -0,0 +1,249 @@ +# 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. + +import pretend +import pytest +import wtforms + +from requests import HTTPError, Timeout +from webob.multidict import MultiDict + +from warehouse.oidc import forms + + +class TestGitHubProviderForm: + @pytest.mark.parametrize( + "token, headers", + [ + ( + None, + {}, + ), + ("fake-token", {"Authorization": "token fake-token"}), + ], + ) + def test_creation(self, token, headers): + form = forms.GitHubProviderForm(api_token=token) + + assert form._api_token == token + assert form._headers_auth() == headers + + def test_lookup_owner_404(self, monkeypatch): + response = pretend.stub( + status_code=404, raise_for_status=pretend.raiser(HTTPError) + ) + requests = pretend.stub( + get=pretend.call_recorder(lambda o, **kw: response), HTTPError=HTTPError + ) + monkeypatch.setattr(forms, "requests", requests) + + form = forms.GitHubProviderForm(api_token="fake-token") + with pytest.raises(wtforms.validators.ValidationError): + form._lookup_owner("some-owner") + + assert requests.get.calls == [ + pretend.call( + "https://api.github.com/users/some-owner", + headers={ + "Accept": "application/vnd.github.v3+json", + "Authorization": "token fake-token", + }, + allow_redirects=True, + ) + ] + + def test_lookup_owner_403(self, monkeypatch): + response = pretend.stub( + status_code=403, + raise_for_status=pretend.raiser(HTTPError), + json=lambda: {"message": "fake-message"}, + ) + requests = pretend.stub( + get=pretend.call_recorder(lambda o, **kw: response), HTTPError=HTTPError + ) + monkeypatch.setattr(forms, "requests", requests) + + sentry_sdk = pretend.stub(capture_message=pretend.call_recorder(lambda s: None)) + monkeypatch.setattr(forms, "sentry_sdk", sentry_sdk) + + form = forms.GitHubProviderForm(api_token="fake-token") + with pytest.raises(wtforms.validators.ValidationError): + form._lookup_owner("some-owner") + + assert requests.get.calls == [ + pretend.call( + "https://api.github.com/users/some-owner", + headers={ + "Accept": "application/vnd.github.v3+json", + "Authorization": "token fake-token", + }, + allow_redirects=True, + ) + ] + assert sentry_sdk.capture_message.calls == [ + pretend.call( + "Exceeded GitHub rate limit for user lookups. " + "Reason: {'message': 'fake-message'}" + ) + ] + + def test_lookup_owner_other_http_error(self, monkeypatch): + response = pretend.stub( + # anything that isn't 404 or 403 + status_code=422, + raise_for_status=pretend.raiser(HTTPError), + content=b"fake-content", + ) + requests = pretend.stub( + get=pretend.call_recorder(lambda o, **kw: response), HTTPError=HTTPError + ) + monkeypatch.setattr(forms, "requests", requests) + + sentry_sdk = pretend.stub(capture_message=pretend.call_recorder(lambda s: None)) + monkeypatch.setattr(forms, "sentry_sdk", sentry_sdk) + + form = forms.GitHubProviderForm(api_token="fake-token") + with pytest.raises(wtforms.validators.ValidationError): + form._lookup_owner("some-owner") + + assert requests.get.calls == [ + pretend.call( + "https://api.github.com/users/some-owner", + headers={ + "Accept": "application/vnd.github.v3+json", + "Authorization": "token fake-token", + }, + allow_redirects=True, + ) + ] + + assert sentry_sdk.capture_message.calls == [ + pretend.call( + "Unexpected error from GitHub user lookup: " + "response.content=b'fake-content'" + ) + ] + + def test_lookup_owner_http_timeout(self, monkeypatch): + requests = pretend.stub( + get=pretend.raiser(Timeout), + Timeout=Timeout, + HTTPError=HTTPError, + ) + monkeypatch.setattr(forms, "requests", requests) + + sentry_sdk = pretend.stub(capture_message=pretend.call_recorder(lambda s: None)) + monkeypatch.setattr(forms, "sentry_sdk", sentry_sdk) + + form = forms.GitHubProviderForm(api_token="fake-token") + with pytest.raises(wtforms.validators.ValidationError): + form._lookup_owner("some-owner") + + assert sentry_sdk.capture_message.calls == [ + pretend.call("Timeout from GitHub user lookup API (possibly offline)") + ] + + def test_lookup_owner_succeeds(self, monkeypatch): + fake_owner_info = pretend.stub() + response = pretend.stub( + status_code=200, + raise_for_status=pretend.call_recorder(lambda: None), + json=lambda: fake_owner_info, + ) + requests = pretend.stub( + get=pretend.call_recorder(lambda o, **kw: response), HTTPError=HTTPError + ) + monkeypatch.setattr(forms, "requests", requests) + + form = forms.GitHubProviderForm(api_token="fake-token") + info = form._lookup_owner("some-owner") + + assert requests.get.calls == [ + pretend.call( + "https://api.github.com/users/some-owner", + headers={ + "Accept": "application/vnd.github.v3+json", + "Authorization": "token fake-token", + }, + allow_redirects=True, + ) + ] + assert response.raise_for_status.calls == [pretend.call()] + assert info == fake_owner_info + + @pytest.mark.parametrize( + "data", + [ + {"owner": None, "repository": "some", "workflow_filename": "some"}, + {"owner": "", "repository": "some", "workflow_filename": "some"}, + { + "owner": "invalid_characters@", + "repository": "some", + "workflow_filename": "some", + }, + {"repository": None, "owner": "some", "workflow_filename": "some"}, + {"repository": "", "owner": "some", "workflow_filename": "some"}, + { + "repository": "$invalid#characters", + "owner": "some", + "workflow_filename": "some", + }, + {"repository": "some", "owner": "some", "workflow_filename": None}, + {"repository": "some", "owner": "some", "workflow_filename": ""}, + ], + ) + def test_validate_basic_invalid_fields(self, monkeypatch, data): + form = forms.GitHubProviderForm(MultiDict(data), api_token=pretend.stub()) + + # We're testing only the basic validation here. + owner_info = {"login": "fake-username", "id": "1234"} + monkeypatch.setattr(form, "_lookup_owner", lambda o: owner_info) + + assert not form.validate() + + def test_validate(self, monkeypatch): + data = MultiDict( + { + "owner": "some-owner", + "repository": "some-repo", + "workflow_filename": "some-workflow.yml", + } + ) + form = forms.GitHubProviderForm(MultiDict(data), api_token=pretend.stub()) + + # We're testing only the basic validation here. + owner_info = {"login": "fake-username", "id": "1234"} + monkeypatch.setattr(form, "_lookup_owner", lambda o: owner_info) + + assert form.validate() + + def test_validate_owner(self, monkeypatch): + form = forms.GitHubProviderForm(api_token=pretend.stub()) + + owner_info = {"login": "some-username", "id": "1234"} + monkeypatch.setattr(form, "_lookup_owner", lambda o: owner_info) + + field = pretend.stub(data="SOME-USERNAME") + form.validate_owner(field) + + assert form.normalized_owner == "some-username" + assert form.owner_id == "1234" + + @pytest.mark.parametrize( + "workflow_filename", ["missing_suffix", "/slash", "/many/slashes", "/slash.yml"] + ) + def test_validate_workflow_filename(self, workflow_filename): + form = forms.GitHubProviderForm(api_token=pretend.stub()) + field = pretend.stub(data=workflow_filename) + + with pytest.raises(wtforms.validators.ValidationError): + form.validate_workflow_filename(field) diff --git a/tests/unit/oidc/test_models.py b/tests/unit/oidc/test_models.py new file mode 100644 index 000000000000..9ffd87bef676 --- /dev/null +++ b/tests/unit/oidc/test_models.py @@ -0,0 +1,130 @@ +# 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. + +import pretend + +from warehouse.oidc import models + + +class TestOIDCProvider: + def test_oidc_provider_not_default_verifiable(self): + provider = models.OIDCProvider(projects=[]) + + assert not provider.verify_claims(signed_claims={}) + + +class TestGitHubProvider: + def test_github_provider_all_known_claims(self): + assert models.GitHubProvider.all_known_claims() == { + # verifiable claims + "repository", + "workflow", + # preverified claims + "iss", + "iat", + "nbf", + "exp", + "aud", + # unchecked claims + "actor", + "jti", + "sub", + "ref", + "sha", + "run_id", + "run_number", + "run_attempt", + "head_ref", + "base_ref", + "event_name", + "ref_type", + "job_workflow_ref", + } + + def test_github_provider_computed_properties(self): + provider = models.GitHubProvider( + repository_name="fakerepo", + owner="fakeowner", + owner_id="fakeid", + workflow_filename="fakeworkflow.yml", + ) + + for claim_name in provider.__verifiable_claims__.keys(): + assert getattr(provider, claim_name) is not None + + assert str(provider) == "fakeworkflow.yml @ fakeowner/fakerepo" + + def test_github_provider_unaccounted_claims(self, monkeypatch): + provider = models.GitHubProvider( + repository_name="fakerepo", + owner="fakeowner", + owner_id="fakeid", + workflow_filename="fakeworkflow.yml", + ) + + sentry_sdk = pretend.stub(capture_message=pretend.call_recorder(lambda s: None)) + monkeypatch.setattr(models, "sentry_sdk", sentry_sdk) + + # We don't care if these actually verify, only that they're present. + signed_claims = { + claim_name: "fake" + for claim_name in models.GitHubProvider.all_known_claims() + } + signed_claims["fake-claim"] = "fake" + assert not provider.verify_claims(signed_claims=signed_claims) + assert sentry_sdk.capture_message.calls == [ + pretend.call( + "JWT for GitHubProvider has unaccounted claims: {'fake-claim'}" + ) + ] + + def test_github_provider_missing_claims(self, monkeypatch): + provider = models.GitHubProvider( + repository_name="fakerepo", + owner="fakeowner", + owner_id="fakeid", + workflow_filename="fakeworkflow.yml", + ) + + sentry_sdk = pretend.stub(capture_message=pretend.call_recorder(lambda s: None)) + monkeypatch.setattr(models, "sentry_sdk", sentry_sdk) + + signed_claims = { + claim_name: "fake" + for claim_name in models.GitHubProvider.all_known_claims() + } + signed_claims.pop("repository") + assert not provider.verify_claims(signed_claims=signed_claims) + assert sentry_sdk.capture_message.calls == [ + pretend.call("JWT for GitHubProvider is missing claim: repository") + ] + + def test_github_provider_verifies(self, monkeypatch): + provider = models.GitHubProvider( + repository_name="fakerepo", + owner="fakeowner", + owner_id="fakeid", + workflow_filename="fakeworkflow.yml", + ) + + noop_check = pretend.call_recorder(lambda l, r: True) + verifiable_claims = { + claim_name: noop_check for claim_name in provider.__verifiable_claims__ + } + monkeypatch.setattr(provider, "__verifiable_claims__", verifiable_claims) + + signed_claims = { + claim_name: "fake" + for claim_name in models.GitHubProvider.all_known_claims() + } + assert provider.verify_claims(signed_claims=signed_claims) + assert len(noop_check.calls) == len(verifiable_claims) diff --git a/tests/unit/oidc/test_services.py b/tests/unit/oidc/test_services.py index 8d91394e5558..46644c85e8f8 100644 --- a/tests/unit/oidc/test_services.py +++ b/tests/unit/oidc/test_services.py @@ -11,8 +11,9 @@ # limitations under the License. import pretend +import pytest -from jwt import PyJWK +from jwt import PyJWK, PyJWTError from zope.interface.verify import verifyClass from warehouse.oidc import interfaces, services @@ -49,14 +50,151 @@ def test_oidc_provider_service_factory(): class TestOIDCProviderService: - def test_verify(self): + def test_verify_signature_only(self, monkeypatch): service = services.OIDCProviderService( provider=pretend.stub(), issuer_url=pretend.stub(), cache_url=pretend.stub(), metrics=pretend.stub(), ) - assert service.verify(pretend.stub()) == NotImplemented + + token = pretend.stub() + decoded = pretend.stub() + jwt = pretend.stub(decode=pretend.call_recorder(lambda t, **kwargs: decoded)) + monkeypatch.setattr( + service, "_get_key_for_token", pretend.call_recorder(lambda t: "fake-key") + ) + monkeypatch.setattr(services, "jwt", jwt) + + assert service.verify_signature_only(token) == decoded + assert jwt.decode.calls == [ + pretend.call( + token, + key="fake-key", + algorithms=["RS256"], + verify_signature=True, + require=["iss", "iat", "nbf", "exp", "aud"], + verify_iss=True, + verify_iat=True, + verify_nbf=True, + verify_exp=True, + verify_aud=True, + issuer=service.issuer_url, + audience="pypi", + leeway=30, + ) + ] + + @pytest.mark.parametrize("exc", [PyJWTError, ValueError]) + def test_verify_signature_only_fails(self, monkeypatch, exc): + service = services.OIDCProviderService( + provider=pretend.stub(), + issuer_url=pretend.stub(), + cache_url=pretend.stub(), + metrics=pretend.stub(), + ) + + token = pretend.stub() + jwt = pretend.stub(decode=pretend.raiser(exc), PyJWTError=PyJWTError) + monkeypatch.setattr( + service, "_get_key_for_token", pretend.call_recorder(lambda t: "fake-key") + ) + monkeypatch.setattr(services, "jwt", jwt) + + assert service.verify_signature_only(token) is None + + def test_verify_for_project(self, monkeypatch): + service = services.OIDCProviderService( + provider="fakeprovider", + issuer_url=pretend.stub(), + cache_url=pretend.stub(), + metrics=pretend.stub( + increment=pretend.call_recorder(lambda *a, **kw: None) + ), + ) + + token = pretend.stub() + claims = pretend.stub() + monkeypatch.setattr( + service, "verify_signature_only", pretend.call_recorder(lambda t: claims) + ) + + provider = pretend.stub(verify_claims=pretend.call_recorder(lambda c: True)) + project = pretend.stub(name="fakeproject", oidc_providers=[provider]) + + assert service.verify_for_project(token, project) + assert service.metrics.increment.calls == [ + pretend.call( + "warehouse.oidc.verify_for_project.attempt", + tags=["project:fakeproject", "provider:fakeprovider"], + ), + pretend.call( + "warehouse.oidc.verify_for_project.ok", + tags=["project:fakeproject", "provider:fakeprovider"], + ), + ] + assert service.verify_signature_only.calls == [pretend.call(token)] + assert provider.verify_claims.calls == [pretend.call(claims)] + + def test_verify_for_project_invalid_signature(self, monkeypatch): + service = services.OIDCProviderService( + provider="fakeprovider", + issuer_url=pretend.stub(), + cache_url=pretend.stub(), + metrics=pretend.stub( + increment=pretend.call_recorder(lambda *a, **kw: None) + ), + ) + + token = pretend.stub() + monkeypatch.setattr(service, "verify_signature_only", lambda t: None) + + project = pretend.stub(name="fakeproject") + + assert not service.verify_for_project(token, project) + assert service.metrics.increment.calls == [ + pretend.call( + "warehouse.oidc.verify_for_project.attempt", + tags=["project:fakeproject", "provider:fakeprovider"], + ), + pretend.call( + "warehouse.oidc.verify_for_project.invalid_signature", + tags=["project:fakeproject", "provider:fakeprovider"], + ), + ] + + def test_verify_for_project_invalid_claims(self, monkeypatch): + service = services.OIDCProviderService( + provider="fakeprovider", + issuer_url=pretend.stub(), + cache_url=pretend.stub(), + metrics=pretend.stub( + increment=pretend.call_recorder(lambda *a, **kw: None) + ), + ) + + token = pretend.stub() + claims = pretend.stub() + monkeypatch.setattr( + service, "verify_signature_only", pretend.call_recorder(lambda t: claims) + ) + + provider = pretend.stub(verify_claims=pretend.call_recorder(lambda c: False)) + project = pretend.stub(name="fakeproject", oidc_providers=[provider]) + + assert not service.verify_for_project(token, project) + assert service.metrics.increment.calls == [ + pretend.call( + "warehouse.oidc.verify_for_project.attempt", + tags=["project:fakeproject", "provider:fakeprovider"], + ), + pretend.call( + "warehouse.oidc.verify_for_project.invalid_claims", + tags=["project:fakeproject", "provider:fakeprovider"], + ), + ] + assert service.verify_signature_only.calls == [pretend.call(token)] + assert provider.verify_claims.calls == [pretend.call(claims)] def test_get_keyset_not_cached(self, monkeypatch, mockredis): service = services.OIDCProviderService( @@ -402,3 +540,25 @@ def test_get_key_refresh_fails(self, monkeypatch): tags=["provider:example", "key_id:fake-key-id"], ) ] + + def test_get_key_for_token(self, monkeypatch): + token = pretend.stub() + key = pretend.stub() + + service = services.OIDCProviderService( + provider="example", + issuer_url="https://example.com", + cache_url="rediss://fake.example.com", + metrics=pretend.stub(), + ) + monkeypatch.setattr(service, "get_key", pretend.call_recorder(lambda kid: key)) + + monkeypatch.setattr( + services.jwt, + "get_unverified_header", + pretend.call_recorder(lambda token: {"kid": "fake-key-id"}), + ) + + assert service._get_key_for_token(token) == key + assert service.get_key.calls == [pretend.call("fake-key-id")] + assert services.jwt.get_unverified_header.calls == [pretend.call(token)] diff --git a/tests/unit/test_config.py b/tests/unit/test_config.py index 872d0c7cf119..e194c2becfe6 100644 --- a/tests/unit/test_config.py +++ b/tests/unit/test_config.py @@ -255,9 +255,12 @@ def __init__(self): "warehouse.account.global_login_ratelimit_string": "1000 per 5 minutes", "warehouse.account.email_add_ratelimit_string": "2 per day", "warehouse.account.password_reset_ratelimit_string": "5 per day", + "warehouse.manage.oidc.user_registration_ratelimit_string": "20 per day", + "warehouse.manage.oidc.ip_registration_ratelimit_string": "20 per day", "warehouse.two_factor_requirement.enabled": False, "warehouse.two_factor_mandate.available": False, "warehouse.two_factor_mandate.enabled": False, + "warehouse.oidc.enabled": False, } if environment == config.Environment.development: expected_settings.update( diff --git a/tests/unit/test_routes.py b/tests/unit/test_routes.py index 634d0a927eb9..c4aac6c899a0 100644 --- a/tests/unit/test_routes.py +++ b/tests/unit/test_routes.py @@ -245,6 +245,13 @@ def add_policy(name, filename): traverse="/{project_name}", domain=warehouse, ), + pretend.call( + "manage.project.settings.publishing", + "/manage/project/{project_name}/settings/publishing/", + factory="warehouse.packaging.models:ProjectFactory", + traverse="/{project_name}", + domain=warehouse, + ), pretend.call( "manage.project.delete_project", "/manage/project/{project_name}/delete_project/", diff --git a/warehouse/accounts/interfaces.py b/warehouse/accounts/interfaces.py index 54a692824d40..adf68da13042 100644 --- a/warehouse/accounts/interfaces.py +++ b/warehouse/accounts/interfaces.py @@ -12,12 +12,7 @@ from zope.interface import Attribute, Interface - -class RateLimiterException(Exception): - def __init__(self, *args, resets_in, **kwargs): - self.resets_in = resets_in - - return super().__init__(*args, **kwargs) +from warehouse.rate_limiting.interfaces import RateLimiterException class TooManyFailedLogins(RateLimiterException): diff --git a/warehouse/admin/flags.py b/warehouse/admin/flags.py index 356a0ba4247a..03082747c611 100644 --- a/warehouse/admin/flags.py +++ b/warehouse/admin/flags.py @@ -22,6 +22,7 @@ class AdminFlagValue(enum.Enum): DISALLOW_NEW_PROJECT_REGISTRATION = "disallow-new-project-registration" DISALLOW_NEW_UPLOAD = "disallow-new-upload" DISALLOW_NEW_USER_REGISTRATION = "disallow-new-user-registration" + DISALLOW_OIDC = "disallow-oidc" READ_ONLY = "read-only" diff --git a/warehouse/config.py b/warehouse/config.py index a39b5c80d522..16d09981286e 100644 --- a/warehouse/config.py +++ b/warehouse/config.py @@ -270,6 +270,18 @@ def configure(settings=None): "PASSWORD_RESET_RATELIMIT_STRING", default="5 per day", ) + maybe_set( + settings, + "warehouse.manage.oidc.user_registration_ratelimit_string", + "USER_OIDC_REGISTRATION_RATELIMIT_STRING", + default="20 per day", + ) + maybe_set( + settings, + "warehouse.manage.oidc.ip_registration_ratelimit_string", + "IP_OIDC_REGISTRATION_RATELIMIT_STRING", + default="20 per day", + ) # 2FA feature flags maybe_set( @@ -294,6 +306,15 @@ def configure(settings=None): default=False, ) + # OIDC feature flags + maybe_set( + settings, + "warehouse.oidc.enabled", + "OIDC_ENABLED", + coercer=distutils.util.strtobool, + default=False, + ) + # Add the settings we use when the environment is set to development. if settings["warehouse.env"] == Environment.development: settings.setdefault("enforce_https", False) diff --git a/warehouse/email/__init__.py b/warehouse/email/__init__.py index b3cbc27f7ea8..752dc4392887 100644 --- a/warehouse/email/__init__.py +++ b/warehouse/email/__init__.py @@ -466,6 +466,28 @@ def send_recovery_code_reminder_email(request, user): return {"username": user.username} +@_email("oidc-provider-added") +def send_oidc_provider_added_email(request, user, project_name, provider): + # We use the request's user, since they're the one triggering the action. + return { + "username": request.user.username, + "project_name": project_name, + "provider_name": provider.provider_name, + "provider_spec": str(provider), + } + + +@_email("oidc-provider-removed") +def send_oidc_provider_removed_email(request, user, project_name, provider): + # We use the request's user, since they're the one triggering the action. + return { + "username": request.user.username, + "project_name": project_name, + "provider_name": provider.provider_name, + "provider_spec": str(provider), + } + + def includeme(config): email_sending_class = config.maybe_dotted(config.registry.settings["mail.backend"]) config.register_service_factory(email_sending_class.create_service, IEmailSender) diff --git a/warehouse/locale/messages.pot b/warehouse/locale/messages.pot index 07d83e0d225d..23279f4b7077 100644 --- a/warehouse/locale/messages.pot +++ b/warehouse/locale/messages.pot @@ -116,7 +116,7 @@ msgstr "" msgid "Successful WebAuthn assertion" msgstr "" -#: warehouse/accounts/views.py:441 warehouse/manage/views.py:791 +#: warehouse/accounts/views.py:441 warehouse/manage/views.py:814 msgid "Recovery code accepted. The supplied code cannot be used again." msgstr "" @@ -225,48 +225,102 @@ msgstr "" msgid "Banner Preview" msgstr "" -#: warehouse/manage/views.py:222 +#: warehouse/manage/views.py:245 msgid "Email ${email_address} added - check your email for a verification link" msgstr "" -#: warehouse/manage/views.py:739 +#: warehouse/manage/views.py:762 msgid "Recovery codes already generated" msgstr "" -#: warehouse/manage/views.py:740 +#: warehouse/manage/views.py:763 msgid "Generating new recovery codes will invalidate your existing codes." msgstr "" -#: warehouse/manage/views.py:1603 +#: warehouse/manage/views.py:1191 +msgid "" +"There have been too many attempted OpenID Connect registrations. Try " +"again later." +msgstr "" + +#: warehouse/manage/views.py:1872 msgid "User '${username}' already has ${role_name} role for project" msgstr "" -#: warehouse/manage/views.py:1614 +#: warehouse/manage/views.py:1883 msgid "" "User '${username}' does not have a verified primary email address and " "cannot be added as a ${role_name} for project" msgstr "" -#: warehouse/manage/views.py:1627 +#: warehouse/manage/views.py:1896 msgid "User '${username}' already has an active invite. Please try again later." msgstr "" -#: warehouse/manage/views.py:1685 +#: warehouse/manage/views.py:1954 msgid "Invitation sent to '${username}'" msgstr "" -#: warehouse/manage/views.py:1732 +#: warehouse/manage/views.py:2001 msgid "Could not find role invitation." msgstr "" -#: warehouse/manage/views.py:1743 +#: warehouse/manage/views.py:2012 msgid "Invitation already expired." msgstr "" -#: warehouse/manage/views.py:1767 +#: warehouse/manage/views.py:2036 msgid "Invitation revoked from '${username}'." msgstr "" +#: warehouse/oidc/forms.py:32 +msgid "Specify GitHub repository owner (username or organization)" +msgstr "" + +#: warehouse/oidc/forms.py:39 +msgid "Specify repository name" +msgstr "" + +#: warehouse/oidc/forms.py:41 +msgid "Invalid repository name" +msgstr "" + +#: warehouse/oidc/forms.py:48 +msgid "Specify workflow filename" +msgstr "" + +#: warehouse/oidc/forms.py:77 +msgid "Unknown GitHub user or organization." +msgstr "" + +#: warehouse/oidc/forms.py:87 +msgid "GitHub has rate-limited this action. Try again in a few minutes." +msgstr "" + +#: warehouse/oidc/forms.py:97 +msgid "Unexpected error from GitHub. Try again." +msgstr "" + +#: warehouse/oidc/forms.py:104 +msgid "Unexpected timeout from GitHub. Try again in a few minutes." +msgstr "" + +#: warehouse/oidc/forms.py:116 +msgid "Invalid GitHub user or organization name." +msgstr "" + +#: warehouse/oidc/forms.py:132 +msgid "Workflow name must end with .yml or .yaml" +msgstr "" + +#: warehouse/oidc/forms.py:137 +msgid "Workflow filename must be a filename only, without directories" +msgstr "" + +#: warehouse/oidc/forms.py:146 +msgid "Provider must be specified by ID" +msgstr "" + #: warehouse/templates/403.html:16 msgid "Access Denied / Forbidden (403)" msgstr "" @@ -842,6 +896,9 @@ msgstr "" #: warehouse/templates/manage/account/recovery_codes-burn.html:70 #: warehouse/templates/manage/account/totp-provision.html:69 #: warehouse/templates/manage/account/webauthn-provision.html:44 +#: warehouse/templates/manage/publishing.html:85 +#: warehouse/templates/manage/publishing.html:97 +#: warehouse/templates/manage/publishing.html:109 #: warehouse/templates/manage/roles.html:170 #: warehouse/templates/manage/roles.html:182 #: warehouse/templates/manage/token.html:136 @@ -1272,6 +1329,76 @@ msgid "" "to publish." msgstr "" +#: warehouse/templates/email/oidc-provider-added/body.html:19 +#, python-format +msgid "" +"\n" +" PyPI user %(username)s has added a new OpenID Connect\n" +" publisher to a project (%(project_name)s) that you " +"manage.\n" +" OpenID Connect publishers act as trusted users and can create project " +"releases\n" +" automatically.\n" +" " +msgstr "" + +#: warehouse/templates/email/oidc-provider-added/body.html:28 +#: warehouse/templates/email/oidc-provider-removed/body.html:26 +msgid "Publisher information" +msgstr "" + +#: warehouse/templates/email/oidc-provider-added/body.html:30 +#: warehouse/templates/email/oidc-provider-removed/body.html:28 +msgid "Publisher name" +msgstr "" + +#: warehouse/templates/email/oidc-provider-added/body.html:31 +#: warehouse/templates/email/oidc-provider-removed/body.html:29 +msgid "Publisher specification" +msgstr "" + +#: warehouse/templates/email/oidc-provider-added/body.html:36 +msgid "" +"\n" +" If you did not make this change and you think it was made maliciously, " +"you can\n" +" remove it from the project via the \"Publishing\" tab on the project's " +"page.\n" +" " +msgstr "" + +#: warehouse/templates/email/oidc-provider-added/body.html:43 +#: warehouse/templates/email/oidc-provider-removed/body.html:41 +#, python-format +msgid "" +"\n" +" If you are unable to revert the change and need to do so, you can email" +"\n" +" %(email_address)s to communicate with the PyPI" +"\n" +" administrators.\n" +" " +msgstr "" + +#: warehouse/templates/email/oidc-provider-removed/body.html:19 +#, python-format +msgid "" +"\n" +" PyPI user %(username)s has removed an OpenID Connect\n" +" publisher from a project (%(project_name)s) that you " +"manage.\n" +" " +msgstr "" + +#: warehouse/templates/email/oidc-provider-removed/body.html:34 +msgid "" +"\n" +" If you did not make this change and you think it was made maliciously, " +"you can\n" +" check the \"Security history\" tab on the project's page.\n" +" " +msgstr "" + #: warehouse/templates/email/password-change/body.html:18 #, python-format msgid "" @@ -1702,7 +1829,11 @@ msgstr "" msgid "Documentation" msgstr "" -#: warehouse/templates/includes/manage/manage-project-menu.html:51 +#: warehouse/templates/includes/manage/manage-project-menu.html:52 +msgid "Publishing" +msgstr "" + +#: warehouse/templates/includes/manage/manage-project-menu.html:59 msgid "Settings" msgstr "" @@ -2600,6 +2731,7 @@ msgstr "" #: warehouse/templates/manage/manage_base.html:64 #: warehouse/templates/manage/manage_base.html:78 +#: warehouse/templates/manage/publishing.html:44 msgid "Remove" msgstr "" @@ -2854,6 +2986,88 @@ msgid "" "rel=\"noopener\">Python Packaging User Guide" msgstr "" +#: warehouse/templates/manage/publishing.html:20 +#: warehouse/templates/manage/publishing.html:51 +msgid "OpenID Connect publisher management" +msgstr "" + +#: warehouse/templates/manage/publishing.html:54 +msgid "" +"OpenID Connect provides a flexible, credential-free mechanism for " +"delegating publishing authority for a PyPI package to a third party " +"service, like GitHub Actions." +msgstr "" + +#: warehouse/templates/manage/publishing.html:62 +msgid "" +"PyPI projects can use trusted OpenID Connect publishers to automate their" +" release processes, without having to explicitly provision or manage API " +"tokens." +msgstr "" + +#: warehouse/templates/manage/publishing.html:68 +msgid "Add a new provider" +msgstr "" + +#: warehouse/templates/manage/publishing.html:72 +#, python-format +msgid "" +"Read more about GitHub's OpenID Connect provider here." +msgstr "" + +#: warehouse/templates/manage/publishing.html:83 +#: warehouse/templates/manage/roles.html:43 +#: warehouse/templates/manage/roles.html:77 +#: warehouse/templates/manage/roles.html:88 +msgid "Owner" +msgstr "" + +#: warehouse/templates/manage/publishing.html:88 +msgid "owner" +msgstr "" + +#: warehouse/templates/manage/publishing.html:95 +msgid "Repository name" +msgstr "" + +#: warehouse/templates/manage/publishing.html:100 +msgid "repository" +msgstr "" + +#: warehouse/templates/manage/publishing.html:107 +msgid "Workflow name" +msgstr "" + +#: warehouse/templates/manage/publishing.html:112 +msgid "workflow.yml" +msgstr "" + +#: warehouse/templates/manage/publishing.html:118 +msgid "Add" +msgstr "" + +#: warehouse/templates/manage/publishing.html:122 +msgid "Manage current providers" +msgstr "" + +#: warehouse/templates/manage/publishing.html:126 +#, python-format +msgid "OpenID Connect publishers associated with %(project_name)s" +msgstr "" + +#: warehouse/templates/manage/publishing.html:130 +msgid "Publisher" +msgstr "" + +#: warehouse/templates/manage/publishing.html:131 +msgid "Specification" +msgstr "" + +#: warehouse/templates/manage/publishing.html:142 +msgid "No publishers are currently configured." +msgstr "" + #: warehouse/templates/manage/release.html:18 #, python-format msgid "Manage '%(project_name)s' – release version %(version)s" @@ -3234,12 +3448,6 @@ msgid "" "delete files, releases, or the project." msgstr "" -#: warehouse/templates/manage/roles.html:43 -#: warehouse/templates/manage/roles.html:77 -#: warehouse/templates/manage/roles.html:88 -msgid "Owner" -msgstr "" - #: warehouse/templates/manage/roles.html:44 msgid "" "Can upload releases. Can invite other collaborators. Can delete files, " diff --git a/warehouse/manage/__init__.py b/warehouse/manage/__init__.py index 7da171d0ce5b..12882f7cc3b3 100644 --- a/warehouse/manage/__init__.py +++ b/warehouse/manage/__init__.py @@ -17,6 +17,7 @@ from warehouse.accounts.forms import ReAuthenticateForm from warehouse.accounts.interfaces import IUserService +from warehouse.rate_limiting import IRateLimiter, RateLimit DEFAULT_TIME_TO_REAUTH = 30 * 60 # 30 minutes @@ -62,3 +63,21 @@ def wrapped(context, request): def includeme(config): config.add_view_deriver(reauth_view, over="rendered_view", under="decorated_view") + + user_oidc_registration_ratelimit_string = config.registry.settings.get( + "warehouse.manage.oidc.user_registration_ratelimit_string" + ) + config.register_service_factory( + RateLimit(user_oidc_registration_ratelimit_string), + IRateLimiter, + name="user_oidc.provider.register", + ) + + ip_oidc_registration_ratelimit_string = config.registry.settings.get( + "warehouse.manage.oidc.ip_registration_ratelimit_string" + ) + config.register_service_factory( + RateLimit(ip_oidc_registration_ratelimit_string), + IRateLimiter, + name="ip_oidc.provider.register", + ) diff --git a/warehouse/manage/views.py b/warehouse/manage/views.py index c22eb2848984..0adc541fb6f0 100644 --- a/warehouse/manage/views.py +++ b/warehouse/manage/views.py @@ -16,7 +16,12 @@ import pyqrcode from paginate_sqlalchemy import SqlalchemyOrmPage as SQLAlchemyORMPage -from pyramid.httpexceptions import HTTPBadRequest, HTTPNotFound, HTTPSeeOther +from pyramid.httpexceptions import ( + HTTPBadRequest, + HTTPNotFound, + HTTPSeeOther, + HTTPTooManyRequests, +) from pyramid.response import Response from pyramid.view import view_config, view_defaults from sqlalchemy import func @@ -41,6 +46,8 @@ send_collaborator_removed_email, send_collaborator_role_changed_email, send_email_verification_email, + send_oidc_provider_added_email, + send_oidc_provider_removed_email, send_password_change_email, send_primary_email_change_email, send_project_role_verification_email, @@ -72,6 +79,10 @@ SaveAccountForm, Toggle2FARequirementForm, ) +from warehouse.metrics.interfaces import IMetricsService +from warehouse.oidc.forms import DeleteProviderForm, GitHubProviderForm +from warehouse.oidc.interfaces import TooManyOIDCRegistrations +from warehouse.oidc.models import GitHubProvider, OIDCProvider from warehouse.packaging.models import ( File, JournalEntry, @@ -82,6 +93,7 @@ RoleInvitation, RoleInvitationStatus, ) +from warehouse.rate_limiting import IRateLimiter from warehouse.utils.http import is_safe_url from warehouse.utils.paginate import paginate_url_factory from warehouse.utils.project import confirm_project, destroy_docs, remove_project @@ -132,6 +144,17 @@ def user_projects(request): } +def project_owners(request, project): + """Return all users who are owners of the project.""" + owner_roles = ( + request.db.query(User.id) + .join(Role.user) + .filter(Role.role_name == "Owner", Role.project == project) + .subquery() + ) + return request.db.query(User).join(owner_roles, User.id == owner_roles.c.id).all() + + @view_defaults( route_name="manage.account", renderer="manage/account.html", @@ -1059,6 +1082,252 @@ def toggle_2fa_requirement(self): ) +@view_defaults( + context=Project, + route_name="manage.project.settings.publishing", + renderer="manage/publishing.html", + uses_session=True, + require_csrf=True, + require_methods=False, + permission="manage:project", + has_translations=True, + require_reauth=True, + http_cache=0, +) +class ManageOIDCProviderViews: + def __init__(self, project, request): + self.request = request + self.project = project + self.oidc_enabled = self.request.registry.settings["warehouse.oidc.enabled"] + self.metrics = self.request.find_service(IMetricsService, context=None) + + @property + def _ratelimiters(self): + return { + "user.oidc": self.request.find_service( + IRateLimiter, name="user_oidc.provider.register" + ), + "ip.oidc": self.request.find_service( + IRateLimiter, name="ip_oidc.provider.register" + ), + } + + def _hit_ratelimits(self): + self._ratelimiters["user.oidc"].hit(self.request.user.id) + self._ratelimiters["ip.oidc"].hit(self.request.remote_addr) + + def _check_ratelimits(self): + if not self._ratelimiters["user.oidc"].test(self.request.user.id): + raise TooManyOIDCRegistrations( + resets_in=self._ratelimiters["user.oidc"].resets_in( + self.request.user.id + ) + ) + + if not self._ratelimiters["ip.oidc"].test(self.request.remote_addr): + raise TooManyOIDCRegistrations( + resets_in=self._ratelimiters["ip.oidc"].resets_in( + self.request.remote_addr + ) + ) + + @property + def github_provider_form(self): + return GitHubProviderForm( + self.request.POST, + api_token=self.request.registry.settings.get("github.token"), + ) + + @property + def default_response(self): + return { + "oidc_enabled": self.oidc_enabled, + "project": self.project, + "github_provider_form": self.github_provider_form, + } + + @view_config(request_method="GET") + def manage_project_oidc_providers(self): + if not self.oidc_enabled: + raise HTTPNotFound + + if self.request.flags.enabled(AdminFlagValue.DISALLOW_OIDC): + self.request.session.flash( + ( + "OpenID Connect is temporarily disabled. " + "See https://pypi.org/help#admin-intervention for details." + ), + queue="error", + ) + + return self.default_response + + @view_config(request_method="POST", request_param=GitHubProviderForm.__params__) + def add_github_oidc_provider(self): + if not self.oidc_enabled: + raise HTTPNotFound + + self.metrics.increment( + "warehouse.oidc.add_provider.attempt", tags=["provider:GitHub"] + ) + + if self.request.flags.enabled(AdminFlagValue.DISALLOW_OIDC): + self.request.session.flash( + ( + "OpenID Connect is temporarily disabled. " + "See https://pypi.org/help#admin-intervention for details." + ), + queue="error", + ) + return self.default_response + + try: + self._check_ratelimits() + except TooManyOIDCRegistrations as exc: + self.metrics.increment( + "warehouse.oidc.add_provider.ratelimited", tags=["provider:GitHub"] + ) + return HTTPTooManyRequests( + self.request._( + "There have been too many attempted OpenID Connect registrations. " + "Try again later." + ), + retry_after=exc.resets_in.total_seconds(), + ) + + self._hit_ratelimits() + + response = self.default_response + form = response["github_provider_form"] + + if form.validate(): + # GitHub OIDC providers are unique on the tuple of + # (repository_name, owner, workflow_filename), so we check for + # an already registered one before creating. + provider = ( + self.request.db.query(GitHubProvider) + .filter( + GitHubProvider.repository_name == form.repository.data, + GitHubProvider.owner == form.normalized_owner, + GitHubProvider.workflow_filename == form.workflow_filename.data, + ) + .one_or_none() + ) + if provider is None: + provider = GitHubProvider( + repository_name=form.repository.data, + owner=form.normalized_owner, + owner_id=form.owner_id, + workflow_filename=form.workflow_filename.data, + ) + + self.request.db.add(provider) + + # Each project has a unique set of OIDC providers; the same + # provider can't be registered to the project more than once. + if provider in self.project.oidc_providers: + self.request.session.flash( + f"{provider} is already registered with {self.project.name}", + queue="error", + ) + return response + + for user in self.project.users: + send_oidc_provider_added_email( + self.request, + user, + project_name=self.project.name, + provider=provider, + ) + + self.project.oidc_providers.append(provider) + + self.project.record_event( + tag="project:oidc:provider-added", + ip_address=self.request.remote_addr, + additional={ + "provider": provider.provider_name, + "id": str(provider.id), + "specifier": str(provider), + }, + ) + + self.request.session.flash( + f"Added {provider} to {self.project.name}", + queue="success", + ) + + self.metrics.increment( + "warehouse.oidc.add_provider.ok", tags=["provider:GitHub"] + ) + + return response + + @view_config(request_method="POST", request_param=DeleteProviderForm.__params__) + def delete_oidc_provider(self): + if not self.oidc_enabled: + raise HTTPNotFound + + self.metrics.increment("warehouse.oidc.delete_provider.attempt") + + if self.request.flags.enabled(AdminFlagValue.DISALLOW_OIDC): + self.request.session.flash( + ( + "OpenID Connect is temporarily disabled. " + "See https://pypi.org/help#admin-intervention for details." + ), + queue="error", + ) + return self.default_response + + form = DeleteProviderForm(self.request.POST) + + if form.validate(): + provider = self.request.db.query(OIDCProvider).get(form.provider_id.data) + + # provider will be `None` here if someone manually futzes with the form. + if provider is None or provider not in self.project.oidc_providers: + self.request.session.flash( + "Invalid publisher for project", + queue="error", + ) + return self.default_response + + for user in self.project.users: + send_oidc_provider_removed_email( + self.request, + user, + project_name=self.project.name, + provider=provider, + ) + + # NOTE: We remove the provider from the project, but we don't actually + # delete the provider model itself (since it might be associated + # with other projects). + self.project.oidc_providers.remove(provider) + + self.project.record_event( + tag="project:oidc:provider-removed", + ip_address=self.request.remote_addr, + additional={ + "provider": provider.provider_name, + "id": str(provider.id), + "specifier": str(provider), + }, + ) + + self.request.session.flash( + f"Removed {provider} from {self.project.name}", queue="success" + ) + + self.metrics.increment( + "warehouse.oidc.delete_provider.ok", + tags=[f"provider:{provider.provider_name}"], + ) + + return self.default_response + + def get_user_role_in_project(project, user, request): return ( request.db.query(Role) @@ -1821,13 +2090,7 @@ def change_project_role(project, request, _form_class=ChangeRoleForm): }, ) - owner_roles = ( - request.db.query(Role) - .filter(Role.project == project) - .filter(Role.role_name == "Owner") - .all() - ) - owner_users = {owner.user for owner in owner_roles} + owner_users = set(project_owners(request, project)) # Don't send owner notification email to new user # if they are now an owner owner_users.discard(role.user) @@ -1898,13 +2161,7 @@ def delete_project_role(project, request): }, ) - owner_roles = ( - request.db.query(Role) - .filter(Role.project == project) - .filter(Role.role_name == "Owner") - .all() - ) - owner_users = {owner.user for owner in owner_roles} + owner_users = set(project_owners(request, project)) # Don't send owner notification email to new user # if they are now an owner owner_users.discard(role.user) diff --git a/warehouse/migrations/versions/f345394c444f_add_initial_oidc_provider_models.py b/warehouse/migrations/versions/f345394c444f_add_initial_oidc_provider_models.py new file mode 100644 index 000000000000..07a04c6e7573 --- /dev/null +++ b/warehouse/migrations/versions/f345394c444f_add_initial_oidc_provider_models.py @@ -0,0 +1,106 @@ +# 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 initial OIDC provider models + +Revision ID: f345394c444f +Revises: fdf9e337538a +Create Date: 2022-02-15 21:11:41.693791 +""" + +import sqlalchemy as sa + +from alembic import op +from sqlalchemy.dialects import postgresql + +revision = "f345394c444f" +down_revision = "fdf9e337538a" + +# Note: It is VERY important to ensure that a migration does not lock for a +# long period of time and to ensure that each individual migration does +# not break compatibility with the *previous* version of the code base. +# This is because the migrations will be ran automatically as part of the +# deployment process, but while the previous version of the code is still +# up and running. Thus backwards incompatible changes must be broken up +# over multiple migrations inside of multiple pull requests in order to +# phase them in over multiple deploys. + + +def upgrade(): + op.create_table( + "oidc_providers", + sa.Column( + "id", + postgresql.UUID(as_uuid=True), + server_default=sa.text("gen_random_uuid()"), + nullable=False, + ), + sa.Column("discriminator", sa.String(), nullable=True), + sa.PrimaryKeyConstraint("id"), + ) + op.create_table( + "github_oidc_providers", + sa.Column("id", postgresql.UUID(as_uuid=True), nullable=False), + sa.Column("repository_name", sa.String(), nullable=True), + sa.Column("owner", sa.String(), nullable=True), + sa.Column("owner_id", sa.String(), nullable=True), + sa.Column("workflow_filename", sa.String(), nullable=True), + sa.ForeignKeyConstraint( + ["id"], + ["oidc_providers.id"], + ), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint( + "repository_name", + "owner", + "workflow_filename", + name="_github_oidc_provider_uc", + ), + ) + op.create_table( + "oidc_provider_project_association", + sa.Column( + "id", + postgresql.UUID(as_uuid=True), + server_default=sa.text("gen_random_uuid()"), + nullable=False, + ), + sa.Column("oidc_provider_id", postgresql.UUID(as_uuid=True), nullable=False), + sa.Column("project_id", postgresql.UUID(as_uuid=True), nullable=False), + sa.ForeignKeyConstraint( + ["oidc_provider_id"], + ["oidc_providers.id"], + ), + sa.ForeignKeyConstraint( + ["project_id"], + ["projects.id"], + ), + sa.PrimaryKeyConstraint("id", "oidc_provider_id", "project_id"), + ) + op.execute( + """ + INSERT INTO admin_flags(id, description, enabled, notify) + VALUES ( + 'disallow-oidc', + 'Disallow ALL OpenID Connect behavior, including authentication', + FALSE, + FALSE + ) + """ + ) + + +def downgrade(): + op.drop_table("oidc_provider_project_association") + op.drop_table("github_oidc_providers") + op.drop_table("oidc_providers") + op.execute("DELETE FROM admin_flags WHERE id = 'disallow-oidc'") diff --git a/warehouse/oidc/forms.py b/warehouse/oidc/forms.py new file mode 100644 index 000000000000..beb77b76474d --- /dev/null +++ b/warehouse/oidc/forms.py @@ -0,0 +1,148 @@ +# 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. + +import re + +import requests +import sentry_sdk +import wtforms + +from warehouse import forms +from warehouse.i18n import localize as _ + +_VALID_GITHUB_REPO = re.compile(r"^[a-zA-Z0-9-_.]+$") +_VALID_GITHUB_OWNER = re.compile(r"^[a-zA-Z0-9][a-zA-Z0-9-]*$") + + +class GitHubProviderForm(forms.Form): + __params__ = ["owner", "repository", "workflow_filename"] + + owner = wtforms.StringField( + validators=[ + wtforms.validators.DataRequired( + message=_("Specify GitHub repository owner (username or organization)"), + ), + ] + ) + + repository = wtforms.StringField( + validators=[ + wtforms.validators.DataRequired(message=_("Specify repository name")), + wtforms.validators.Regexp( + _VALID_GITHUB_REPO, message=_("Invalid repository name") + ), + ] + ) + + workflow_filename = wtforms.StringField( + validators=[ + wtforms.validators.DataRequired(message=_("Specify workflow filename")) + ] + ) + + def __init__(self, *args, api_token, **kwargs): + super().__init__(*args, **kwargs) + self._api_token = api_token + + def _headers_auth(self): + if not self._api_token: + return {} + return {"Authorization": f"token {self._api_token}"} + + def _lookup_owner(self, owner): + # To actually validate the owner, we ask GitHub's API about them. + # We can't do this for the repository, since it might be private. + try: + response = requests.get( + f"https://api.github.com/users/{owner}", + headers={ + "Accept": "application/vnd.github.v3+json", + **self._headers_auth(), + }, + allow_redirects=True, + ) + response.raise_for_status() + except requests.HTTPError: + if response.status_code == 404: + raise wtforms.validators.ValidationError( + _("Unknown GitHub user or organization.") + ) + if response.status_code == 403: + # GitHub's API uses 403 to signal rate limiting, and returns a JSON + # blob explaining the reason. + sentry_sdk.capture_message( + "Exceeded GitHub rate limit for user lookups. " + f"Reason: {response.json()}" + ) + raise wtforms.validators.ValidationError( + _( + "GitHub has rate-limited this action. " + "Try again in a few minutes." + ) + ) + else: + sentry_sdk.capture_message( + f"Unexpected error from GitHub user lookup: {response.content=}" + ) + raise wtforms.validators.ValidationError( + _("Unexpected error from GitHub. Try again.") + ) + except requests.Timeout: + sentry_sdk.capture_message( + "Timeout from GitHub user lookup API (possibly offline)" + ) + raise wtforms.validators.ValidationError( + _("Unexpected timeout from GitHub. Try again in a few minutes.") + ) + + return response.json() + + def validate_owner(self, field): + owner = field.data + + # We pre-filter owners with a regex, to avoid loading GitHub's API + # with usernames/org names that will never be valid. + if not _VALID_GITHUB_OWNER.match(owner): + raise wtforms.validators.ValidationError( + _("Invalid GitHub user or organization name.") + ) + + owner_info = self._lookup_owner(owner) + + # NOTE: Use the normalized owner name as provided by GitHub. + self.normalized_owner = owner_info["login"] + self.owner_id = owner_info["id"] + + def validate_workflow_filename(self, field): + workflow_filename = field.data + + if not ( + workflow_filename.endswith(".yml") or workflow_filename.endswith(".yaml") + ): + raise wtforms.validators.ValidationError( + _("Workflow name must end with .yml or .yaml") + ) + + if "/" in workflow_filename: + raise wtforms.validators.ValidationError( + _("Workflow filename must be a filename only, without directories") + ) + + +class DeleteProviderForm(forms.Form): + __params__ = ["provider_id"] + + provider_id = wtforms.StringField( + validators=[ + wtforms.validators.UUID(message=_("Provider must be specified by ID")) + ] + ) diff --git a/warehouse/oidc/interfaces.py b/warehouse/oidc/interfaces.py index 4c1ea13e9297..74899faa26af 100644 --- a/warehouse/oidc/interfaces.py +++ b/warehouse/oidc/interfaces.py @@ -13,6 +13,8 @@ from zope.interface import Interface +from warehouse.rate_limiting.interfaces import RateLimiterException + class IOIDCProviderService(Interface): def get_key(key_id): @@ -26,7 +28,24 @@ def get_key(key_id): """ pass - def verify(token): + def verify_signature_only(token): + """ + Verify the given JWT's signature and basic claims, returning + the decoded JWT, or `None` if invalid. + + This function **does not** verify the token's suitability + for a particular action; subsequent checks on the decoded token's + third party claims must be done to ensure that. """ - Verify the given JWT. + + def verify_for_project(token, project): """ + Verify the given JWT's signature and basic claims in the same + manner as `verify_signature_only`, but *also* verify that the JWT's + claims are consistent with at least one of the project's registered + OIDC providers. + """ + + +class TooManyOIDCRegistrations(RateLimiterException): + pass diff --git a/warehouse/oidc/models.py b/warehouse/oidc/models.py new file mode 100644 index 000000000000..f2f780671890 --- /dev/null +++ b/warehouse/oidc/models.py @@ -0,0 +1,182 @@ +# 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. + + +from typing import Any, Callable, Dict, Set + +import sentry_sdk + +from sqlalchemy import Column, ForeignKey, String, UniqueConstraint, orm +from sqlalchemy.dialects.postgresql import UUID + +from warehouse import db +from warehouse.packaging.models import Project + + +class OIDCProviderProjectAssociation(db.Model): + __tablename__ = "oidc_provider_project_association" + + oidc_provider_id = Column( + UUID(as_uuid=True), + ForeignKey("oidc_providers.id"), + nullable=False, + primary_key=True, + ) + project_id = Column( + UUID(as_uuid=True), ForeignKey("projects.id"), nullable=False, primary_key=True + ) + + +class OIDCProvider(db.Model): + __tablename__ = "oidc_providers" + + discriminator = Column(String) + projects = orm.relationship( + Project, + secondary=OIDCProviderProjectAssociation.__table__, # type: ignore + backref="oidc_providers", + ) + + __mapper_args__ = { + "polymorphic_identity": "oidc_providers", + "polymorphic_on": discriminator, + } + + # A map of claim names to "check" functions, each of which + # has the signature `check(ground-truth, signed-claim) -> bool`. + __verifiable_claims__: Dict[str, Callable[[Any, Any], bool]] = dict() + + # Claims that have already been verified during the JWT signature + # verification phase. + __preverified_claims__ = { + "iss", + "iat", + "nbf", + "exp", + "aud", + } + + # Individual providers should explicitly override this set, + # indicating any custom claims that are known to be present but are + # not checked as part of verifying the JWT. + __unchecked_claims__: Set[str] = set() + + @classmethod + def all_known_claims(cls): + """ + Returns all claims "known" to this provider. + """ + return ( + cls.__verifiable_claims__.keys() + | cls.__preverified_claims__ + | cls.__unchecked_claims__ + ) + + def verify_claims(self, signed_claims): + """ + Given a JWT that has been successfully decoded (checked for a valid + signature and basic claims), verify it against the more specific + claims of this provider. + """ + + # Defensive programming: treat the absence of any claims to verify + # as a failure rather than trivially valid. + if not self.__verifiable_claims__: + return False + + # All claims should be accounted for. + # The presence of an unaccounted claim is not an error, only a warning + # that the JWT payload has changed. + unaccounted_claims = signed_claims.keys() - self.all_known_claims() + if unaccounted_claims: + sentry_sdk.capture_message( + f"JWT for {self.__class__.__name__} has unaccounted claims: " + f"{unaccounted_claims}" + ) + + # Finally, perform the actual claim verification. + for claim_name, check in self.__verifiable_claims__.items(): + # All verifiable claims are mandatory. The absence of a missing + # claim *is* an error, since it indicates a breaking change in the + # JWT's payload. + signed_claim = signed_claims.get(claim_name) + if signed_claim is None: + sentry_sdk.capture_message( + f"JWT for {self.__class__.__name__} is missing claim: {claim_name}" + ) + return False + + if not check(getattr(self, claim_name), signed_claim): + return False + + return True + + @property + def provider_name(self): # pragma: no cover + # Only concrete subclasses of OIDCProvider are constructed. + return NotImplemented + + +class GitHubProvider(OIDCProvider): + __tablename__ = "github_oidc_providers" + __mapper_args__ = {"polymorphic_identity": "github_oidc_providers"} + __table_args__ = ( + UniqueConstraint( + "repository_name", + "owner", + "workflow_filename", + name="_github_oidc_provider_uc", + ), + ) + + id = Column(UUID(as_uuid=True), ForeignKey(OIDCProvider.id), primary_key=True) + repository_name = Column(String) + owner = Column(String) + owner_id = Column(String) + workflow_filename = Column(String) + + __verifiable_claims__ = { + "repository": str.__eq__, + "workflow": str.__eq__, + } + + __unchecked_claims__ = { + "actor", + "jti", + "sub", + "ref", + "sha", + "run_id", + "run_number", + "run_attempt", + "head_ref", + "base_ref", + "event_name", + "ref_type", + # TODO(#11096): Support reusable workflows. + "job_workflow_ref", + } + + @property + def provider_name(self): + return "GitHub" + + @property + def repository(self): + return f"{self.owner}/{self.repository_name}" + + @property + def workflow(self): + return self.workflow_filename + + def __str__(self): + return f"{self.workflow_filename} @ {self.repository}" diff --git a/warehouse/oidc/services.py b/warehouse/oidc/services.py index f84a116a6cf0..9c679ed095ec 100644 --- a/warehouse/oidc/services.py +++ b/warehouse/oidc/services.py @@ -12,11 +12,11 @@ import json +import jwt import redis import requests import sentry_sdk -from jwt import PyJWK from zope.interface import implementer from warehouse.metrics.interfaces import IMetricsService @@ -148,10 +148,87 @@ def get_key(self, key_id): tags=[f"provider:{self.provider}", f"key_id:{key_id}"], ) return None - return PyJWK(keyset[key_id]) + return jwt.PyJWK(keyset[key_id]) - def verify(self, token): - return NotImplemented + def _get_key_for_token(self, token): + """ + Return a JWK suitable for verifying the given JWT. + + The JWT is not verified at this point, and this step happens + prior to any verification. + """ + unverified_header = jwt.get_unverified_header(token) + return self.get_key(unverified_header["kid"]) + + def verify_signature_only(self, token): + key = self._get_key_for_token(token) + + try: + # NOTE: Many of the keyword arguments here are defaults, but we + # set them explicitly to assert the intended verification behavior. + signed_payload = jwt.decode( + token, + key=key, + algorithms=["RS256"], + verify_signature=True, + # "require" only checks for the presence of these claims, not + # their validity. Each has a corresponding "verify_" kwarg + # that enforces their actual validity. + require=["iss", "iat", "nbf", "exp", "aud"], + verify_iss=True, + verify_iat=True, + verify_nbf=True, + verify_exp=True, + verify_aud=True, + issuer=self.issuer_url, + audience="pypi", + leeway=30, + ) + return signed_payload + except jwt.PyJWTError: + return None + except Exception as e: + # We expect pyjwt to only raise subclasses of PyJWTError, but + # we can't enforce this. Other exceptions indicate an abstraction + # leak, so we log them for upstream reporting. + sentry_sdk.capture_message(f"JWT verify raised generic error: {e}") + return None + + def verify_for_project(self, token, project): + signed_payload = self.verify_signature_only(token) + + metrics_tags = [f"project:{project.name}", f"provider:{self.provider}"] + self.metrics.increment( + "warehouse.oidc.verify_for_project.attempt", + tags=metrics_tags, + ) + + if signed_payload is None: + self.metrics.increment( + "warehouse.oidc.verify_for_project.invalid_signature", + tags=metrics_tags, + ) + return False + + # In order for a signed JWT to be valid for a particular PyPI project, + # it must match at least one of the OIDC providers registered to + # the project. + verified = any( + provider.verify_claims(signed_payload) + for provider in project.oidc_providers + ) + if not verified: + self.metrics.increment( + "warehouse.oidc.verify_for_project.invalid_claims", + tags=metrics_tags, + ) + else: + self.metrics.increment( + "warehouse.oidc.verify_for_project.ok", + tags=metrics_tags, + ) + + return verified class OIDCProviderServiceFactory: diff --git a/warehouse/rate_limiting/interfaces.py b/warehouse/rate_limiting/interfaces.py index 876122751b70..aac632e621fa 100644 --- a/warehouse/rate_limiting/interfaces.py +++ b/warehouse/rate_limiting/interfaces.py @@ -38,3 +38,10 @@ def clear(*identifiers): """ Clears the rate limiter identified by the identifiers. """ + + +class RateLimiterException(Exception): + def __init__(self, *args, resets_in, **kwargs): + self.resets_in = resets_in + + return super().__init__(*args, **kwargs) diff --git a/warehouse/routes.py b/warehouse/routes.py index e9d6006af7ed..31aa0eaba377 100644 --- a/warehouse/routes.py +++ b/warehouse/routes.py @@ -229,6 +229,13 @@ def includeme(config): traverse="/{project_name}", domain=warehouse, ) + config.add_route( + "manage.project.settings.publishing", + "/manage/project/{project_name}/settings/publishing/", + factory="warehouse.packaging.models:ProjectFactory", + traverse="/{project_name}", + domain=warehouse, + ) config.add_route( "manage.project.delete_project", "/manage/project/{project_name}/delete_project/", diff --git a/warehouse/templates/email/oidc-provider-added/body.html b/warehouse/templates/email/oidc-provider-added/body.html new file mode 100644 index 000000000000..01544af9ce06 --- /dev/null +++ b/warehouse/templates/email/oidc-provider-added/body.html @@ -0,0 +1,49 @@ +{# + # 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. +-#} +{% extends "email/_base/body.html" %} + + +{% block content %} +

+ {% trans username=username, project_name=project_name %} + PyPI user {{ username }} has added a new OpenID Connect + publisher to a project ({{project_name}}) that you manage. + OpenID Connect publishers act as trusted users and can create project releases + automatically. + {% endtrans %} +

+ +

+ {% trans %}Publisher information{% endtrans %}: +

+

+ +

+ {% trans %} + If you did not make this change and you think it was made maliciously, you can + remove it from the project via the "Publishing" tab on the project's page. + {% endtrans %} +

+ +

+ {% trans href='mailto:admin@pypi.org', email_address='admin@pypi.org' %} + If you are unable to revert the change and need to do so, you can email + {{ email_address }} to communicate with the PyPI + administrators. + {% endtrans %} +

+{% endblock %} diff --git a/warehouse/templates/email/oidc-provider-added/body.txt b/warehouse/templates/email/oidc-provider-added/body.txt new file mode 100644 index 000000000000..bdf0c8ac4b0e --- /dev/null +++ b/warehouse/templates/email/oidc-provider-added/body.txt @@ -0,0 +1,39 @@ +{# + # 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. +-#} +{% extends "email/_base/body.txt" %} + +{% block content %} +{% trans username=username, project_name=project_name %} +PyPI user {{ username }} has added a new OpenID Connect publisher to a project +({{ project_name }}) that you manage. OpenID Connect publishers act as trusted +users and can create project releases automatically. +{% endtrans %} + +{% trans %}Publisher information{% endtrans %}: + +* {% trans %}Publisher name{% endtrans %}: {{ provider_name }} +* {% trans %}Publisher specification{% endtrans %}: {{ provider_spec }} + +{% trans %} +If you did not make this change and you think it was made maliciously, you can +remove it from the project via the "Publishing" tab on the project's page. +{% endtrans %} + +{% trans email_address='admin@pypi.org' %} +If you are unable to revert the change and need to do so, you can email +{{ email_address }} to communicate with the PyPI administrators. +{% endtrans %} + +{% endblock %} + diff --git a/warehouse/templates/email/oidc-provider-added/subject.txt b/warehouse/templates/email/oidc-provider-added/subject.txt new file mode 100644 index 000000000000..178d873e16fd --- /dev/null +++ b/warehouse/templates/email/oidc-provider-added/subject.txt @@ -0,0 +1,21 @@ +{# + # 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. +-#} + +{% extends "email/_base/subject.txt" %} + +{% block subject %} +{% trans project_name=project_name %} +OpenID Connect publisher added to {{ project_name }} +{% endtrans %} +{% endblock %} diff --git a/warehouse/templates/email/oidc-provider-removed/body.html b/warehouse/templates/email/oidc-provider-removed/body.html new file mode 100644 index 000000000000..d90cedcb152e --- /dev/null +++ b/warehouse/templates/email/oidc-provider-removed/body.html @@ -0,0 +1,47 @@ +{# + # 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. +-#} +{% extends "email/_base/body.html" %} + + +{% block content %} +

+ {% trans username=username, project_name=project_name %} + PyPI user {{ username }} has removed an OpenID Connect + publisher from a project ({{project_name}}) that you manage. + {% endtrans %} +

+ +

+ {% trans %}Publisher information{% endtrans %}: +

+

+ +

+ {% trans %} + If you did not make this change and you think it was made maliciously, you can + check the "Security history" tab on the project's page. + {% endtrans %} +

+ +

+ {% trans href='mailto:admin@pypi.org', email_address='admin@pypi.org' %} + If you are unable to revert the change and need to do so, you can email + {{ email_address }} to communicate with the PyPI + administrators. + {% endtrans %} +

+{% endblock %} diff --git a/warehouse/templates/email/oidc-provider-removed/body.txt b/warehouse/templates/email/oidc-provider-removed/body.txt new file mode 100644 index 000000000000..93e1606824c0 --- /dev/null +++ b/warehouse/templates/email/oidc-provider-removed/body.txt @@ -0,0 +1,38 @@ +{# + # 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. +-#} +{% extends "email/_base/body.txt" %} + +{% block content %} +{% trans username=username, project_name=project_name %} +PyPI user {{ username }} has removed an OpenID Connect publisher from a project +({{ project_name }}) that you manage. +{% endtrans %} + +{% trans %}Publisher information{% endtrans %}: + +* {% trans %}Publisher name{% endtrans %}: {{ provider_name }} +* {% trans %}Publisher specification{% endtrans %}: {{ provider_spec }} + +{% trans %} +If you did not make this change and you think it was made maliciously, you can +check the "Security history" tab on the project's page. +{% endtrans %} + +{% trans email_address='admin@pypi.org' %} +If you are unable to revert the change and need to do so, you can email +{{ email_address }} to communicate with the PyPI administrators. +{% endtrans %} + +{% endblock %} + diff --git a/warehouse/templates/email/oidc-provider-removed/subject.txt b/warehouse/templates/email/oidc-provider-removed/subject.txt new file mode 100644 index 000000000000..8e76331c6d35 --- /dev/null +++ b/warehouse/templates/email/oidc-provider-removed/subject.txt @@ -0,0 +1,21 @@ +{# + # 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. +-#} + +{% extends "email/_base/subject.txt" %} + +{% block subject %} +{% trans project_name=project_name %} +OpenID Connect publisher removed from {{ project_name }} +{% endtrans %} +{% endblock %} diff --git a/warehouse/templates/includes/manage/manage-project-menu.html b/warehouse/templates/includes/manage/manage-project-menu.html index 62350ee3a4d7..d8dfb5b72eb0 100644 --- a/warehouse/templates/includes/manage/manage-project-menu.html +++ b/warehouse/templates/includes/manage/manage-project-menu.html @@ -45,6 +45,14 @@ {% endif %} + {% if request.registry.settings["warehouse.oidc.enabled"] %} +
  • + + + {% trans %}Publishing{% endtrans %} + +
  • + {% endif %}
  • diff --git a/warehouse/templates/manage/account.html b/warehouse/templates/manage/account.html index ab386455972a..d4be82cae493 100644 --- a/warehouse/templates/manage/account.html +++ b/warehouse/templates/manage/account.html @@ -69,9 +69,9 @@ {% macro email_row(email) -%} - + {{ email.email }} - + {% if email.primary %} diff --git a/warehouse/templates/manage/publishing.html b/warehouse/templates/manage/publishing.html new file mode 100644 index 000000000000..5bc91e1f5a30 --- /dev/null +++ b/warehouse/templates/manage/publishing.html @@ -0,0 +1,148 @@ +{# + # 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. +-#} + +{% extends "manage_project_base.html" %} + +{% set active_tab = 'publishing' %} + +{% block title %} + {% trans %}OpenID Connect publisher management{% endtrans %} +{% endblock %} + +{% block main %} +{% if testPyPI %} +{% set title = "TestPyPI" %} +{% else %} +{% set title = "PyPI" %} +{% endif %} + +{% macro provider_row(provider) -%} +
    + + +
    + + + + {{ provider.provider_name }} + + + {{ provider|string }} + + + + + +{%- endmacro %} + +
    +
    +

    {% trans %}OpenID Connect publisher management{% endtrans %}

    + +

    + {% trans trimmed %} + OpenID Connect provides a flexible, credential-free mechanism for delegating + publishing authority for a PyPI package to a third party service, + like GitHub Actions. + {% endtrans %} +

    + +

    + {% trans trimmed %} + PyPI projects can use trusted OpenID Connect publishers to automate their release + processes, without having to explicitly provision or manage API tokens. + {% endtrans %} +

    + +

    {% trans %}Add a new provider{% endtrans %}

    +

    GitHub

    + +

    + {% trans trimmed href="https://docs.github.com/en/actions/deployment/security-hardening-your-deployments/about-security-hardening-with-openid-connect" %} + Read more about GitHub's OpenID Connect provider here. + {% endtrans %} +

    + + {{ form_error_anchor(github_provider_form) }} +
    + + {{ form_errors(github_provider_form) }} +
    + + {{ github_provider_form.owner(placeholder=gettext("owner"), autocomplete="off", autocapitalize="off", spellcheck="false", class_="form-group__field", aria_describedby="owner-errors") }} +
    + {{ field_errors(github_provider_form.owner) }} +
    +
    +
    + + {{ github_provider_form.repository(placeholder=gettext("repository"), autocomplete="off", autocapitalize="off", spellcheck="false", class_="form-group__field", **{"aria-describedby":"repository-errors"}) }} +
    + {{ field_errors(github_provider_form.repository) }} +
    +
    +
    + + {{ github_provider_form.workflow_filename(placeholder=gettext("workflow.yml"), class_="form-group__field", autocomplete="off", **{"aria-describedby":"workflow_filename-errors"}) }} +
    + {{ field_errors(github_provider_form.workflow_filename) }} +
    +
    +
    + +
    +
    + +

    {% trans %}Manage current providers{% endtrans %}

    + {% if project.oidc_providers %} + + + + + + + + + + + {% for provider in project.oidc_providers %} + {{ provider_row(provider) }} + {% endfor %} + +
    + {% trans project_name=project.name %}OpenID Connect publishers associated with {{ project_name }}{% endtrans %} +
    {% trans %}Publisher{% endtrans %}{% trans %}Specification{% endtrans %}
    + {% else %} +

    {% trans %}No publishers are currently configured.{% endtrans %}

    + {% endif %} +
    +
    + +{% endblock %} +