diff --git a/tests/unit/accounts/test_views.py b/tests/unit/accounts/test_views.py
index fb3e8a085e8f..00fc0c901d9f 100644
--- a/tests/unit/accounts/test_views.py
+++ b/tests/unit/accounts/test_views.py
@@ -19,7 +19,12 @@
import pytest
import pytz
-from pyramid.httpexceptions import HTTPMovedPermanently, HTTPSeeOther
+from pyramid.httpexceptions import (
+ HTTPMovedPermanently,
+ HTTPNotFound,
+ HTTPSeeOther,
+ HTTPTooManyRequests,
+)
from sqlalchemy.orm.exc import NoResultFound
from webauthn.authentication.verify_authentication_response import (
VerifiedAuthentication,
@@ -42,6 +47,8 @@
from warehouse.accounts.views import two_factor_and_totp_validate
from warehouse.admin.flags import AdminFlag, AdminFlagValue
from warehouse.events.tags import EventTag
+from warehouse.metrics.interfaces import IMetricsService
+from warehouse.oidc.interfaces import TooManyOIDCRegistrations
from warehouse.organizations.models import (
OrganizationInvitation,
OrganizationRole,
@@ -2890,3 +2897,905 @@ def test_reauth_no_user(self, monkeypatch, pyramid_request):
assert isinstance(result, HTTPSeeOther)
assert pyramid_request.route_path.calls == [pretend.call("accounts.login")]
assert result.headers["Location"] == "/the-redirect"
+
+
+class TestManageAccountPublishingViews:
+ def test_initializes(self):
+ metrics = pretend.stub()
+ request = pretend.stub(
+ registry=pretend.stub(settings={"warehouse.oidc.enabled": True}),
+ find_service=pretend.call_recorder(lambda *a, **kw: metrics),
+ )
+ view = views.ManageAccountPublishingViews(request)
+
+ 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):
+ 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.ManageAccountPublishingViews(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_publishing(self, monkeypatch):
+ metrics = pretend.stub()
+ request = pretend.stub(
+ registry=pretend.stub(
+ settings={
+ "warehouse.oidc.enabled": True,
+ "github.token": "fake-api-token",
+ }
+ ),
+ find_service=pretend.call_recorder(lambda *a, **kw: metrics),
+ flags=pretend.stub(enabled=pretend.call_recorder(lambda f: False)),
+ POST=pretend.stub(),
+ )
+
+ project_factory = pretend.stub()
+ project_factory_cls = pretend.call_recorder(lambda r: project_factory)
+ monkeypatch.setattr(views, "ProjectFactory", project_factory_cls)
+
+ pending_github_provider_form_obj = pretend.stub()
+ pending_github_provider_form_cls = pretend.call_recorder(
+ lambda *a, **kw: pending_github_provider_form_obj
+ )
+ monkeypatch.setattr(
+ views, "PendingGitHubProviderForm", pending_github_provider_form_cls
+ )
+
+ view = views.ManageAccountPublishingViews(request)
+
+ assert view.manage_publishing() == {
+ "oidc_enabled": True,
+ "pending_github_provider_form": pending_github_provider_form_obj,
+ }
+
+ assert request.flags.enabled.calls == [
+ pretend.call(AdminFlagValue.DISALLOW_OIDC)
+ ]
+ assert project_factory_cls.calls == [pretend.call(request)]
+ assert pending_github_provider_form_cls.calls == [
+ pretend.call(
+ request.POST,
+ api_token="fake-api-token",
+ project_factory=project_factory,
+ )
+ ]
+
+ def test_manage_publishing_oidc_disabled(self):
+ request = pretend.stub(
+ registry=pretend.stub(settings={"warehouse.oidc.enabled": False}),
+ find_service=lambda *a, **kw: None,
+ )
+
+ view = views.ManageAccountPublishingViews(request)
+
+ with pytest.raises(HTTPNotFound):
+ view.manage_publishing()
+
+ def test_manage_publishing_admin_disabled(self, monkeypatch):
+ request = pretend.stub(
+ registry=pretend.stub(
+ settings={
+ "warehouse.oidc.enabled": True,
+ "github.token": "fake-api-token",
+ }
+ ),
+ find_service=pretend.call_recorder(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(),
+ )
+
+ project_factory = pretend.stub()
+ project_factory_cls = pretend.call_recorder(lambda r: project_factory)
+ monkeypatch.setattr(views, "ProjectFactory", project_factory_cls)
+
+ pending_github_provider_form_obj = pretend.stub()
+ pending_github_provider_form_cls = pretend.call_recorder(
+ lambda *a, **kw: pending_github_provider_form_obj
+ )
+ monkeypatch.setattr(
+ views, "PendingGitHubProviderForm", pending_github_provider_form_cls
+ )
+
+ view = views.ManageAccountPublishingViews(request)
+
+ assert view.manage_publishing() == {
+ "oidc_enabled": True,
+ "pending_github_provider_form": pending_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 pending_github_provider_form_cls.calls == [
+ pretend.call(
+ request.POST,
+ api_token="fake-api-token",
+ project_factory=project_factory,
+ )
+ ]
+
+ def test_add_pending_github_oidc_provider_oidc_disabled(self):
+ request = pretend.stub(
+ registry=pretend.stub(settings={"warehouse.oidc.enabled": False}),
+ find_service=lambda *a, **kw: None,
+ )
+
+ view = views.ManageAccountPublishingViews(request)
+
+ with pytest.raises(HTTPNotFound):
+ view.add_pending_github_oidc_provider()
+
+ def test_add_pending_github_oidc_provider_admin_disabled(self, monkeypatch):
+ request = pretend.stub(
+ registry=pretend.stub(
+ settings={
+ "warehouse.oidc.enabled": True,
+ "github.token": "fake-api-token",
+ }
+ ),
+ find_service=pretend.call_recorder(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(),
+ )
+
+ project_factory = pretend.stub()
+ project_factory_cls = pretend.call_recorder(lambda r: project_factory)
+ monkeypatch.setattr(views, "ProjectFactory", project_factory_cls)
+
+ pending_github_provider_form_obj = pretend.stub()
+ pending_github_provider_form_cls = pretend.call_recorder(
+ lambda *a, **kw: pending_github_provider_form_obj
+ )
+ monkeypatch.setattr(
+ views, "PendingGitHubProviderForm", pending_github_provider_form_cls
+ )
+
+ view = views.ManageAccountPublishingViews(request)
+
+ assert view.add_pending_github_oidc_provider() == {
+ "oidc_enabled": True,
+ "pending_github_provider_form": pending_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 pending_github_provider_form_cls.calls == [
+ pretend.call(
+ request.POST,
+ api_token="fake-api-token",
+ project_factory=project_factory,
+ )
+ ]
+
+ def test_add_pending_github_oidc_provider_user_cannot_register(self, monkeypatch):
+ 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=pretend.call_recorder(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(),
+ user=pretend.stub(has_primary_verified_email=False),
+ _=lambda s: s,
+ )
+
+ project_factory = pretend.stub()
+ project_factory_cls = pretend.call_recorder(lambda r: project_factory)
+ monkeypatch.setattr(views, "ProjectFactory", project_factory_cls)
+
+ pending_github_provider_form_obj = pretend.stub()
+ pending_github_provider_form_cls = pretend.call_recorder(
+ lambda *a, **kw: pending_github_provider_form_obj
+ )
+ monkeypatch.setattr(
+ views, "PendingGitHubProviderForm", pending_github_provider_form_cls
+ )
+
+ view = views.ManageAccountPublishingViews(request)
+
+ assert view.add_pending_github_oidc_provider() == {
+ "oidc_enabled": True,
+ "pending_github_provider_form": pending_github_provider_form_obj,
+ }
+
+ assert request.flags.enabled.calls == [
+ pretend.call(AdminFlagValue.DISALLOW_OIDC)
+ ]
+ assert view.metrics.increment.calls == [
+ pretend.call(
+ "warehouse.oidc.add_pending_provider.attempt", tags=["provider:GitHub"]
+ ),
+ ]
+ assert request.session.flash.calls == [
+ pretend.call(
+ (
+ "You must have a verified email in order to register a "
+ "pending OpenID Connect provider. "
+ "See https://pypi.org/help#openid-connect for details."
+ ),
+ queue="error",
+ )
+ ]
+ assert pending_github_provider_form_cls.calls == [
+ pretend.call(
+ request.POST,
+ api_token="fake-api-token",
+ project_factory=project_factory,
+ )
+ ]
+
+ def test_add_pending_github_oidc_provider_too_many_already(self, monkeypatch):
+ 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=pretend.call_recorder(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(),
+ user=pretend.stub(
+ has_primary_verified_email=True,
+ pending_oidc_providers=[pretend.stub(), pretend.stub(), pretend.stub()],
+ ),
+ _=lambda s: s,
+ )
+
+ project_factory = pretend.stub()
+ project_factory_cls = pretend.call_recorder(lambda r: project_factory)
+ monkeypatch.setattr(views, "ProjectFactory", project_factory_cls)
+
+ pending_github_provider_form_obj = pretend.stub()
+ pending_github_provider_form_cls = pretend.call_recorder(
+ lambda *a, **kw: pending_github_provider_form_obj
+ )
+ monkeypatch.setattr(
+ views, "PendingGitHubProviderForm", pending_github_provider_form_cls
+ )
+
+ view = views.ManageAccountPublishingViews(request)
+
+ assert view.add_pending_github_oidc_provider() == {
+ "oidc_enabled": True,
+ "pending_github_provider_form": pending_github_provider_form_obj,
+ }
+
+ assert request.flags.enabled.calls == [
+ pretend.call(AdminFlagValue.DISALLOW_OIDC)
+ ]
+ assert view.metrics.increment.calls == [
+ pretend.call(
+ "warehouse.oidc.add_pending_provider.attempt", tags=["provider:GitHub"]
+ ),
+ ]
+ assert request.session.flash.calls == [
+ pretend.call(
+ (
+ "You can't register more than 3 pending OpenID Connect "
+ "providers at once."
+ ),
+ queue="error",
+ )
+ ]
+ assert pending_github_provider_form_cls.calls == [
+ pretend.call(
+ request.POST,
+ api_token="fake-api-token",
+ project_factory=project_factory,
+ )
+ ]
+
+ def test_add_pending_github_oidc_provider_ratelimited(self, monkeypatch):
+ 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=pretend.call_recorder(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(),
+ user=pretend.stub(
+ has_primary_verified_email=True,
+ pending_oidc_providers=[],
+ ),
+ _=lambda s: s,
+ )
+
+ project_factory = pretend.stub()
+ project_factory_cls = pretend.call_recorder(lambda r: project_factory)
+ monkeypatch.setattr(views, "ProjectFactory", project_factory_cls)
+
+ pending_github_provider_form_obj = pretend.stub()
+ pending_github_provider_form_cls = pretend.call_recorder(
+ lambda *a, **kw: pending_github_provider_form_obj
+ )
+ monkeypatch.setattr(
+ views, "PendingGitHubProviderForm", pending_github_provider_form_cls
+ )
+
+ view = views.ManageAccountPublishingViews(request)
+ monkeypatch.setattr(
+ view,
+ "_check_ratelimits",
+ pretend.call_recorder(
+ pretend.raiser(
+ TooManyOIDCRegistrations(
+ resets_in=pretend.stub(total_seconds=lambda: 60)
+ )
+ )
+ ),
+ )
+
+ assert view.add_pending_github_oidc_provider().__class__ == HTTPTooManyRequests
+ assert view.metrics.increment.calls == [
+ pretend.call(
+ "warehouse.oidc.add_pending_provider.attempt", tags=["provider:GitHub"]
+ ),
+ pretend.call(
+ "warehouse.oidc.add_pending_provider.ratelimited",
+ tags=["provider:GitHub"],
+ ),
+ ]
+
+ def test_add_pending_github_oidc_provider_invalid_form(self, monkeypatch):
+ 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=pretend.call_recorder(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(),
+ user=pretend.stub(
+ has_primary_verified_email=True,
+ pending_oidc_providers=[],
+ ),
+ _=lambda s: s,
+ )
+
+ project_factory = pretend.stub()
+ project_factory_cls = pretend.call_recorder(lambda r: project_factory)
+ monkeypatch.setattr(views, "ProjectFactory", project_factory_cls)
+
+ pending_github_provider_form_obj = pretend.stub(
+ validate=pretend.call_recorder(lambda: False),
+ )
+ pending_github_provider_form_cls = pretend.call_recorder(
+ lambda *a, **kw: pending_github_provider_form_obj
+ )
+ monkeypatch.setattr(
+ views, "PendingGitHubProviderForm", pending_github_provider_form_cls
+ )
+
+ view = views.ManageAccountPublishingViews(request)
+ default_response = {
+ "pending_github_provider_form": pending_github_provider_form_obj
+ }
+ monkeypatch.setattr(
+ views.ManageAccountPublishingViews, "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_pending_github_oidc_provider() == default_response
+ assert view.metrics.increment.calls == [
+ pretend.call(
+ "warehouse.oidc.add_pending_provider.attempt", tags=["provider:GitHub"]
+ ),
+ ]
+ assert view._hit_ratelimits.calls == [pretend.call()]
+ assert view._check_ratelimits.calls == [pretend.call()]
+ assert pending_github_provider_form_obj.validate.calls == [pretend.call()]
+
+ def test_add_pending_github_oidc_provider_already_exists(self, monkeypatch):
+ metrics = pretend.stub(increment=pretend.call_recorder(lambda *a, **kw: None))
+ pending_provider = pretend.stub()
+ request = pretend.stub(
+ registry=pretend.stub(
+ settings={
+ "warehouse.oidc.enabled": True,
+ "github.token": "fake-api-token",
+ }
+ ),
+ find_service=pretend.call_recorder(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(),
+ user=pretend.stub(
+ has_primary_verified_email=True,
+ pending_oidc_providers=[],
+ ),
+ _=lambda s: s,
+ db=pretend.stub(
+ query=lambda q: pretend.stub(
+ filter_by=lambda **kw: pretend.stub(first=lambda: pending_provider)
+ )
+ ),
+ )
+
+ project_factory = pretend.stub()
+ project_factory_cls = pretend.call_recorder(lambda r: project_factory)
+ monkeypatch.setattr(views, "ProjectFactory", project_factory_cls)
+
+ pending_github_provider_form_obj = pretend.stub(
+ validate=pretend.call_recorder(lambda: True),
+ repository=pretend.stub(data="some-repo"),
+ normalized_owner="some-owner",
+ workflow_filename=pretend.stub(data="some-workflow.yml"),
+ )
+ pending_github_provider_form_cls = pretend.call_recorder(
+ lambda *a, **kw: pending_github_provider_form_obj
+ )
+ monkeypatch.setattr(
+ views, "PendingGitHubProviderForm", pending_github_provider_form_cls
+ )
+
+ view = views.ManageAccountPublishingViews(request)
+ default_response = {
+ "pending_github_provider_form": pending_github_provider_form_obj
+ }
+ monkeypatch.setattr(
+ views.ManageAccountPublishingViews, "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_pending_github_oidc_provider() == default_response
+ assert view.metrics.increment.calls == [
+ pretend.call(
+ "warehouse.oidc.add_pending_provider.attempt", tags=["provider:GitHub"]
+ ),
+ ]
+ assert view._hit_ratelimits.calls == [pretend.call()]
+ assert view._check_ratelimits.calls == [pretend.call()]
+ assert pending_github_provider_form_obj.validate.calls == [pretend.call()]
+ assert request.session.flash.calls == [
+ pretend.call(
+ (
+ "This OpenID Connect provider has already been registered. "
+ "Please contact PyPI's admins if this wasn't intentional."
+ ),
+ queue="error",
+ )
+ ]
+
+ def test_add_pending_github_oidc_provider(self, monkeypatch):
+ 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=pretend.call_recorder(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(),
+ user=pretend.stub(
+ has_primary_verified_email=True,
+ pending_oidc_providers=[],
+ record_event=pretend.call_recorder(lambda **kw: None),
+ ),
+ _=lambda s: s,
+ db=pretend.stub(
+ query=lambda q: pretend.stub(
+ filter_by=lambda **kw: pretend.stub(first=lambda: None)
+ ),
+ add=pretend.call_recorder(lambda o: None),
+ ),
+ path="some-path",
+ remote_addr="0.0.0.0",
+ )
+
+ project_factory = pretend.stub()
+ project_factory_cls = pretend.call_recorder(lambda r: project_factory)
+ monkeypatch.setattr(views, "ProjectFactory", project_factory_cls)
+
+ pending_provider = pretend.stub(
+ project_name="some-project-name",
+ provider_name="some-provider",
+ id=uuid.uuid4(),
+ )
+ # NOTE: Can't set __str__ using pretend.stub()
+ monkeypatch.setattr(
+ pending_provider.__class__, "__str__", lambda s: "fakespecifier"
+ )
+
+ pending_provider_cls = pretend.call_recorder(lambda **kw: pending_provider)
+ monkeypatch.setattr(views, "PendingGitHubProvider", pending_provider_cls)
+
+ pending_github_provider_form_obj = pretend.stub(
+ validate=pretend.call_recorder(lambda: True),
+ project_name=pretend.stub(data="some-project-name"),
+ repository=pretend.stub(data="some-repo"),
+ normalized_owner="some-owner",
+ owner_id="some-owner-id",
+ workflow_filename=pretend.stub(data="some-workflow.yml"),
+ )
+ pending_github_provider_form_cls = pretend.call_recorder(
+ lambda *a, **kw: pending_github_provider_form_obj
+ )
+ monkeypatch.setattr(
+ views, "PendingGitHubProviderForm", pending_github_provider_form_cls
+ )
+
+ view = views.ManageAccountPublishingViews(request)
+ default_response = {
+ "pending_github_provider_form": pending_github_provider_form_obj
+ }
+ monkeypatch.setattr(
+ views.ManageAccountPublishingViews, "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_pending_github_oidc_provider().__class__ == HTTPSeeOther
+ assert view.metrics.increment.calls == [
+ pretend.call(
+ "warehouse.oidc.add_pending_provider.attempt", tags=["provider:GitHub"]
+ ),
+ pretend.call(
+ "warehouse.oidc.add_pending_provider.ok", tags=["provider:GitHub"]
+ ),
+ ]
+ assert view._hit_ratelimits.calls == [pretend.call()]
+ assert view._check_ratelimits.calls == [pretend.call()]
+ assert pending_github_provider_form_obj.validate.calls == [pretend.call()]
+
+ assert pending_provider_cls.calls == [
+ pretend.call(
+ project_name="some-project-name",
+ added_by=request.user,
+ repository_name="some-repo",
+ repository_owner="some-owner",
+ repository_owner_id="some-owner-id",
+ workflow_filename="some-workflow.yml",
+ )
+ ]
+ assert request.db.add.calls == [pretend.call(pending_provider)]
+ assert request.user.record_event.calls == [
+ pretend.call(
+ tag=EventTag.Account.PendingOIDCProviderAdded,
+ ip_address="0.0.0.0",
+ additional={
+ "project": "some-project-name",
+ "provider": "some-provider",
+ "id": str(pending_provider.id),
+ "specifier": "fakespecifier",
+ },
+ )
+ ]
+
+ assert request.session.flash.calls == [
+ pretend.call(
+ "Registered a new publishing provider to create "
+ f"the project '{pending_provider.project_name}'.",
+ queue="success",
+ )
+ ]
+
+ def test_delete_pending_oidc_provider_oidc_disabled(self):
+ request = pretend.stub(
+ registry=pretend.stub(settings={"warehouse.oidc.enabled": False}),
+ find_service=lambda *a, **kw: None,
+ )
+
+ view = views.ManageAccountPublishingViews(request)
+
+ with pytest.raises(HTTPNotFound):
+ view.delete_pending_oidc_provider()
+
+ def test_delete_pending_oidc_provider_admin_disabled(self, monkeypatch):
+ request = pretend.stub(
+ registry=pretend.stub(
+ settings={
+ "warehouse.oidc.enabled": True,
+ "github.token": "fake-api-token",
+ }
+ ),
+ find_service=pretend.call_recorder(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(),
+ )
+
+ project_factory = pretend.stub()
+ project_factory_cls = pretend.call_recorder(lambda r: project_factory)
+ monkeypatch.setattr(views, "ProjectFactory", project_factory_cls)
+
+ pending_github_provider_form_obj = pretend.stub()
+ pending_github_provider_form_cls = pretend.call_recorder(
+ lambda *a, **kw: pending_github_provider_form_obj
+ )
+ monkeypatch.setattr(
+ views, "PendingGitHubProviderForm", pending_github_provider_form_cls
+ )
+
+ view = views.ManageAccountPublishingViews(request)
+
+ assert view.delete_pending_oidc_provider() == {
+ "oidc_enabled": True,
+ "pending_github_provider_form": pending_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 pending_github_provider_form_cls.calls == [
+ pretend.call(
+ request.POST,
+ api_token="fake-api-token",
+ project_factory=project_factory,
+ )
+ ]
+
+ def test_delete_pending_oidc_provider_invalid_form(self, monkeypatch):
+ 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.ManageAccountPublishingViews(request)
+ default_response = {"_": pretend.stub()}
+ monkeypatch.setattr(
+ views.ManageAccountPublishingViews, "default_response", default_response
+ )
+
+ assert view.delete_pending_oidc_provider() == default_response
+
+ assert view.metrics.increment.calls == [
+ pretend.call(
+ "warehouse.oidc.delete_pending_provider.attempt",
+ ),
+ ]
+
+ assert delete_provider_form_cls.calls == [pretend.call(request.POST)]
+ assert delete_provider_form_obj.validate.calls == [pretend.call()]
+
+ def test_delete_pending_oidc_provider_not_found(self, monkeypatch):
+ 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(),
+ db=pretend.stub(query=lambda m: pretend.stub(get=lambda id: None)),
+ session=pretend.stub(flash=pretend.call_recorder(lambda *a, **kw: None)),
+ )
+
+ delete_provider_form_obj = pretend.stub(
+ validate=pretend.call_recorder(lambda: True),
+ provider_id=pretend.stub(data="some-id"),
+ )
+ delete_provider_form_cls = pretend.call_recorder(
+ lambda *a, **kw: delete_provider_form_obj
+ )
+ monkeypatch.setattr(views, "DeleteProviderForm", delete_provider_form_cls)
+
+ view = views.ManageAccountPublishingViews(request)
+ default_response = {"_": pretend.stub()}
+ monkeypatch.setattr(
+ views.ManageAccountPublishingViews, "default_response", default_response
+ )
+
+ assert view.delete_pending_oidc_provider() == default_response
+
+ assert view.metrics.increment.calls == [
+ pretend.call(
+ "warehouse.oidc.delete_pending_provider.attempt",
+ ),
+ ]
+ assert delete_provider_form_cls.calls == [pretend.call(request.POST)]
+ assert delete_provider_form_obj.validate.calls == [pretend.call()]
+ assert request.session.flash.calls == [
+ pretend.call(
+ "Invalid publisher for user",
+ queue="error",
+ )
+ ]
+
+ def test_delete_pending_oidc_provider(self, monkeypatch):
+ metrics = pretend.stub(increment=pretend.call_recorder(lambda *a, **kw: None))
+ pending_provider = pretend.stub(
+ project_name="some-project-name",
+ provider_name="some-provider",
+ id=uuid.uuid4(),
+ )
+ # NOTE: Can't set __str__ using pretend.stub()
+ monkeypatch.setattr(
+ pending_provider.__class__, "__str__", lambda s: "fakespecifier"
+ )
+ 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(),
+ db=pretend.stub(
+ query=lambda m: pretend.stub(get=lambda id: pending_provider),
+ delete=pretend.call_recorder(lambda m: None),
+ ),
+ session=pretend.stub(flash=pretend.call_recorder(lambda *a, **kw: None)),
+ user=pretend.stub(record_event=pretend.call_recorder(lambda **kw: None)),
+ remote_addr="0.0.0.0",
+ path="some-path",
+ )
+
+ delete_provider_form_obj = pretend.stub(
+ validate=pretend.call_recorder(lambda: True),
+ provider_id=pretend.stub(data="some-id"),
+ )
+ delete_provider_form_cls = pretend.call_recorder(
+ lambda *a, **kw: delete_provider_form_obj
+ )
+ monkeypatch.setattr(views, "DeleteProviderForm", delete_provider_form_cls)
+
+ view = views.ManageAccountPublishingViews(request)
+ default_response = {"_": pretend.stub()}
+ monkeypatch.setattr(
+ views.ManageAccountPublishingViews, "default_response", default_response
+ )
+
+ assert view.delete_pending_oidc_provider().__class__ == HTTPSeeOther
+
+ assert view.metrics.increment.calls == [
+ pretend.call(
+ "warehouse.oidc.delete_pending_provider.attempt",
+ ),
+ pretend.call(
+ "warehouse.oidc.delete_pending_provider.ok",
+ tags=["provider:some-provider"],
+ ),
+ ]
+ assert delete_provider_form_cls.calls == [pretend.call(request.POST)]
+ assert delete_provider_form_obj.validate.calls == [pretend.call()]
+ assert request.session.flash.calls == [
+ pretend.call(
+ "Removed provider for project 'some-project-name'", queue="success"
+ )
+ ]
+ assert request.user.record_event.calls == [
+ pretend.call(
+ tag=EventTag.Account.PendingOIDCProviderRemoved,
+ ip_address="0.0.0.0",
+ additional={
+ "project": "some-project-name",
+ "provider": "some-provider",
+ "id": str(pending_provider.id),
+ "specifier": str(pending_provider),
+ },
+ )
+ ]
+ assert request.db.delete.calls == [pretend.call(pending_provider)]
diff --git a/tests/unit/manage/test_views.py b/tests/unit/manage/test_views.py
index 587361f49473..fb11d3f9411a 100644
--- a/tests/unit/manage/test_views.py
+++ b/tests/unit/manage/test_views.py
@@ -9959,10 +9959,9 @@ def test_add_github_oidc_provider_oidc_not_enabled(self):
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,
+ 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)),
_=lambda s: s,
@@ -9975,11 +9974,6 @@ def test_add_github_oidc_provider_admin_disabled(self, monkeypatch):
)
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(
(
@@ -10336,10 +10330,9 @@ def test_delete_oidc_provider_oidc_not_enabled(self):
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,
+ 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)),
)
@@ -10351,11 +10344,6 @@ def test_delete_oidc_provider_admin_disabled(self, monkeypatch):
)
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(
(
diff --git a/tests/unit/oidc/test_forms.py b/tests/unit/oidc/test_forms.py
index 8558c9543a73..93ea5ed9024c 100644
--- a/tests/unit/oidc/test_forms.py
+++ b/tests/unit/oidc/test_forms.py
@@ -14,12 +14,51 @@
import pytest
import wtforms
-from requests import HTTPError, Timeout
+from requests import ConnectionError, HTTPError, Timeout
from webob.multidict import MultiDict
from warehouse.oidc import forms
+class TestPendingGitHubProviderForm:
+ def test_creation(self):
+ project_factory = pretend.stub()
+ form = forms.PendingGitHubProviderForm(
+ api_token="fake-token", project_factory=project_factory
+ )
+
+ assert form._project_factory == project_factory
+
+ def test_validate_project_name_already_in_use(self):
+ project_factory = ["some-project"]
+ form = forms.PendingGitHubProviderForm(
+ api_token="fake-token", project_factory=project_factory
+ )
+
+ field = pretend.stub(data="some-project")
+ with pytest.raises(wtforms.validators.ValidationError):
+ form.validate_project_name(field)
+
+ def test_validate(self, monkeypatch):
+ data = MultiDict(
+ {
+ "owner": "some-owner",
+ "repository": "some-repo",
+ "workflow_filename": "some-workflow.yml",
+ "project_name": "some-project",
+ }
+ )
+ form = forms.PendingGitHubProviderForm(
+ MultiDict(data), api_token=pretend.stub(), project_factory=[]
+ )
+
+ # 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()
+
+
class TestGitHubProviderForm:
@pytest.mark.parametrize(
"token, headers",
@@ -138,6 +177,7 @@ def test_lookup_owner_http_timeout(self, monkeypatch):
get=pretend.raiser(Timeout),
Timeout=Timeout,
HTTPError=HTTPError,
+ ConnectionError=ConnectionError,
)
monkeypatch.setattr(forms, "requests", requests)
@@ -152,6 +192,28 @@ def test_lookup_owner_http_timeout(self, monkeypatch):
pretend.call("Timeout from GitHub user lookup API (possibly offline)")
]
+ def test_lookup_owner_connection_error(self, monkeypatch):
+ requests = pretend.stub(
+ get=pretend.raiser(ConnectionError),
+ Timeout=Timeout,
+ HTTPError=HTTPError,
+ ConnectionError=ConnectionError,
+ )
+ 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(
+ "Connection error from GitHub user lookup API (possibly offline)"
+ )
+ ]
+
def test_lookup_owner_succeeds(self, monkeypatch):
fake_owner_info = pretend.stub()
response = pretend.stub(
diff --git a/tests/unit/test_routes.py b/tests/unit/test_routes.py
index 282efd9d6c12..5dad6b80ffdc 100644
--- a/tests/unit/test_routes.py
+++ b/tests/unit/test_routes.py
@@ -197,6 +197,9 @@ def add_policy(name, filename):
domain=warehouse,
),
pretend.call("manage.account", "/manage/account/", domain=warehouse),
+ pretend.call(
+ "manage.account.publishing", "/manage/account/publishing/", domain=warehouse
+ ),
pretend.call(
"manage.account.two-factor",
"/manage/account/two-factor/",
diff --git a/warehouse/accounts/views.py b/warehouse/accounts/views.py
index 5bf7e5fc3b13..4d36e846acee 100644
--- a/warehouse/accounts/views.py
+++ b/warehouse/accounts/views.py
@@ -20,11 +20,12 @@
from first import first
from pyramid.httpexceptions import (
HTTPMovedPermanently,
+ HTTPNotFound,
HTTPSeeOther,
HTTPTooManyRequests,
)
from pyramid.security import forget, remember
-from pyramid.view import view_config
+from pyramid.view import view_config, view_defaults
from sqlalchemy.orm.exc import NoResultFound
from webauthn.helpers import bytes_to_base64url
@@ -67,11 +68,16 @@
send_recovery_code_reminder_email,
)
from warehouse.events.tags import EventTag
+from warehouse.metrics.interfaces import IMetricsService
+from warehouse.oidc.forms import DeleteProviderForm, PendingGitHubProviderForm
+from warehouse.oidc.interfaces import TooManyOIDCRegistrations
+from warehouse.oidc.models import PendingGitHubProvider, PendingOIDCProvider
from warehouse.organizations.interfaces import IOrganizationService
from warehouse.organizations.models import OrganizationRole, OrganizationRoleType
from warehouse.packaging.models import (
JournalEntry,
Project,
+ ProjectFactory,
Release,
Role,
RoleInvitation,
@@ -1288,3 +1294,265 @@ def reauthenticate(request, _form_class=ReAuthenticateForm):
)
return resp
+
+
+@view_defaults(
+ route_name="manage.account.publishing",
+ renderer="manage/account/publishing.html",
+ uses_session=True,
+ require_csrf=True,
+ require_methods=False,
+ permission="manage:user",
+ has_translations=True,
+ require_reauth=True,
+)
+class ManageAccountPublishingViews:
+ def __init__(self, request):
+ self.request = request
+ self.oidc_enabled = self.request.registry.settings["warehouse.oidc.enabled"]
+ self.project_factory = ProjectFactory(request)
+ 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 pending_github_provider_form(self):
+ return PendingGitHubProviderForm(
+ self.request.POST,
+ api_token=self.request.registry.settings.get("github.token"),
+ project_factory=self.project_factory,
+ )
+
+ @property
+ def default_response(self):
+ return {
+ "oidc_enabled": self.oidc_enabled,
+ "pending_github_provider_form": self.pending_github_provider_form,
+ }
+
+ @view_config(request_method="GET")
+ def manage_publishing(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
+
+ return self.default_response
+
+ @view_config(
+ request_method="POST", request_param=PendingGitHubProviderForm.__params__
+ )
+ def add_pending_github_oidc_provider(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
+
+ self.metrics.increment(
+ "warehouse.oidc.add_pending_provider.attempt", tags=["provider:GitHub"]
+ )
+
+ if not self.request.user.has_primary_verified_email:
+ self.request.session.flash(
+ self.request._(
+ "You must have a verified email in order to register a "
+ "pending OpenID Connect provider. "
+ "See https://pypi.org/help#openid-connect for details."
+ ),
+ queue="error",
+ )
+ return self.default_response
+
+ # Separately from having permission to register pending OIDC providers,
+ # we limit users to no more than 3 pending providers at once.
+ if len(self.request.user.pending_oidc_providers) >= 3:
+ self.request.session.flash(
+ self.request._(
+ "You can't register more than 3 pending OpenID Connect "
+ "providers at once."
+ ),
+ queue="error",
+ )
+ return self.default_response
+
+ try:
+ self._check_ratelimits()
+ except TooManyOIDCRegistrations as exc:
+ self.metrics.increment(
+ "warehouse.oidc.add_pending_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["pending_github_provider_form"]
+
+ if not form.validate():
+ return response
+
+ provider_already_exists = (
+ self.request.db.query(PendingGitHubProvider)
+ .filter_by(
+ repository_name=form.repository.data,
+ repository_owner=form.normalized_owner,
+ workflow_filename=form.workflow_filename.data,
+ )
+ .first()
+ is not None
+ )
+
+ if provider_already_exists:
+ self.request.session.flash(
+ self.request._(
+ "This OpenID Connect provider has already been registered. "
+ "Please contact PyPI's admins if this wasn't intentional."
+ ),
+ queue="error",
+ )
+ return response
+
+ pending_provider = PendingGitHubProvider(
+ project_name=form.project_name.data,
+ added_by=self.request.user,
+ repository_name=form.repository.data,
+ repository_owner=form.normalized_owner,
+ repository_owner_id=form.owner_id,
+ workflow_filename=form.workflow_filename.data,
+ )
+
+ self.request.db.add(pending_provider)
+
+ self.request.user.record_event(
+ tag=EventTag.Account.PendingOIDCProviderAdded,
+ ip_address=self.request.remote_addr,
+ additional={
+ "project": pending_provider.project_name,
+ "provider": pending_provider.provider_name,
+ "id": str(pending_provider.id),
+ "specifier": str(pending_provider),
+ },
+ )
+
+ self.request.session.flash(
+ "Registered a new publishing provider to create "
+ f"the project '{pending_provider.project_name}'.",
+ queue="success",
+ )
+
+ self.metrics.increment(
+ "warehouse.oidc.add_pending_provider.ok", tags=["provider:GitHub"]
+ )
+
+ return HTTPSeeOther(self.request.path)
+
+ @view_config(request_method="POST", request_param=DeleteProviderForm.__params__)
+ def delete_pending_oidc_provider(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
+
+ self.metrics.increment("warehouse.oidc.delete_pending_provider.attempt")
+
+ form = DeleteProviderForm(self.request.POST)
+
+ if form.validate():
+ pending_provider = self.request.db.query(PendingOIDCProvider).get(
+ form.provider_id.data
+ )
+
+ # pending_provider will be `None` here if someone manually
+ # futzes with the form.
+ if pending_provider is None:
+ self.request.session.flash(
+ "Invalid publisher for user",
+ queue="error",
+ )
+ return self.default_response
+
+ self.request.session.flash(
+ f"Removed provider for project '{pending_provider.project_name}'",
+ queue="success",
+ )
+
+ self.metrics.increment(
+ "warehouse.oidc.delete_pending_provider.ok",
+ tags=[f"provider:{pending_provider.provider_name}"],
+ )
+
+ self.request.user.record_event(
+ tag=EventTag.Account.PendingOIDCProviderRemoved,
+ ip_address=self.request.remote_addr,
+ additional={
+ "project": pending_provider.project_name,
+ "provider": pending_provider.provider_name,
+ "id": str(pending_provider.id),
+ "specifier": str(pending_provider),
+ },
+ )
+
+ self.request.db.delete(pending_provider)
+
+ return HTTPSeeOther(self.request.path)
+
+ return self.default_response
diff --git a/warehouse/events/tags.py b/warehouse/events/tags.py
index 3ec3b5e740a5..54ba623b587e 100644
--- a/warehouse/events/tags.py
+++ b/warehouse/events/tags.py
@@ -84,6 +84,8 @@ class Account(EventTagEnum):
PasswordReset = "account:password:reset"
PasswordResetAttempt = "account:password:reset:attempt"
PasswordResetRequest = "account:password:reset:request"
+ PendingOIDCProviderAdded = "account:oidc:pending-provider-added"
+ PendingOIDCProviderRemoved = "account:oidc:pending-provider-removed"
RecoveryCodesGenerated = "account:recovery_codes:generated"
RecoveryCodesRegenerated = "account:recovery_codes:regenerated"
RecoveryCodesUsed = "account:recovery_codes:used"
diff --git a/warehouse/forklift/legacy.py b/warehouse/forklift/legacy.py
index c36acd084d9b..ff19f61e2580 100644
--- a/warehouse/forklift/legacy.py
+++ b/warehouse/forklift/legacy.py
@@ -63,7 +63,7 @@
)
from warehouse.packaging.tasks import update_bigquery_release_files
from warehouse.utils import http, readme
-from warehouse.utils.project import add_project, validate_project_name
+from warehouse.utils.project import PROJECT_NAME_RE, add_project, validate_project_name
from warehouse.utils.security_policy import AuthenticationMethod
ONE_MB = 1 * 1024 * 1024
@@ -189,11 +189,6 @@ def _valid_platform_tag(platform_tag):
)
-_project_name_re = re.compile(
- r"^([A-Z0-9]|[A-Z0-9][A-Z0-9._-]*[A-Z0-9])$", re.IGNORECASE
-)
-
-
_legacy_specifier_re = re.compile(r"^(?P We use a number of terms to describe software available on PyPI, like "
@@ -6169,7 +6284,7 @@ msgid ""
"href=\"%(wheel_href)s\">wheel..github/workflows/
directory in the repository configured "
+"above."
+msgstr ""
+
+#: warehouse/templates/manage/account/publishing.html:117
+#: warehouse/templates/manage/project/publishing.html:95
+#: warehouse/templates/manage/project/roles.html:320
+#: warehouse/templates/manage/team/roles.html:123
+msgid "Add"
+msgstr ""
+
+#: warehouse/templates/manage/account/publishing.html:121
+msgid "Manage pending providers"
+msgstr ""
+
+#: warehouse/templates/manage/account/publishing.html:126
+msgid "Pending project name"
+msgstr ""
+
+#: warehouse/templates/manage/account/publishing.html:127
+#: warehouse/templates/manage/project/publishing.html:107
+msgid "Publisher"
+msgstr ""
+
+#: warehouse/templates/manage/account/publishing.html:128
+msgid "Publishing workflow URL"
+msgstr ""
+
+#: warehouse/templates/manage/account/publishing.html:139
+#: warehouse/templates/manage/project/publishing.html:119
+msgid "No publishers are currently configured."
+msgstr ""
+
#: warehouse/templates/manage/account/recovery_codes-burn.html:34
msgid ""
"In order to verify that you have safely stored your recovery codes for "
@@ -3888,10 +4066,6 @@ msgstr ""
msgid "Select scope..."
msgstr ""
-#: warehouse/templates/manage/account/token.html:158
-msgid "Project:"
-msgstr ""
-
#: warehouse/templates/manage/account/token.html:167
msgid ""
"An API token scoped to your entire account will have upload permissions "
@@ -4801,16 +4975,6 @@ msgstr ""
msgid "OpenID Connect provider added"
msgstr ""
-#: warehouse/templates/manage/project/history.html:223
-#: warehouse/templates/manage/project/history.html:227
-msgid "Provider:"
-msgstr ""
-
-#: warehouse/templates/manage/project/history.html:224
-#: warehouse/templates/manage/project/history.html:228
-msgid "Publisher:"
-msgstr ""
-
#: warehouse/templates/manage/project/history.html:226
msgid "OpenID Connect provider removed"
msgstr ""
@@ -4840,95 +5004,42 @@ msgstr ""
msgid "Back to projects"
msgstr ""
-#: warehouse/templates/manage/project/publishing.html:20
-#: warehouse/templates/manage/project/publishing.html:51
-msgid "OpenID Connect publisher management"
-msgstr ""
-
-#: warehouse/templates/manage/project/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/project/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/project/publishing.html:68
+#: warehouse/templates/manage/project/publishing.html:36
msgid "Add a new provider"
msgstr ""
-#: warehouse/templates/manage/project/publishing.html:72
+#: warehouse/templates/manage/project/publishing.html:40
#, python-format
msgid ""
"Read more about GitHub's OpenID Connect provider here."
msgstr ""
-#: warehouse/templates/manage/project/publishing.html:88
-msgid "owner"
-msgstr ""
-
-#: warehouse/templates/manage/project/publishing.html:90
+#: warehouse/templates/manage/project/publishing.html:58
msgid "The organization name or username that owns the repository"
msgstr ""
-#: warehouse/templates/manage/project/publishing.html:98
-msgid "Repository name"
-msgstr ""
-
-#: warehouse/templates/manage/project/publishing.html:103
-msgid "repository"
-msgstr ""
-
-#: warehouse/templates/manage/project/publishing.html:105
+#: warehouse/templates/manage/project/publishing.html:73
msgid "The name of the repository that contains the publishing workflow"
msgstr ""
-#: warehouse/templates/manage/project/publishing.html:113
-msgid "Workflow name"
-msgstr ""
-
-#: warehouse/templates/manage/project/publishing.html:118
-msgid "workflow.yml"
-msgstr ""
-
-#: warehouse/templates/manage/project/publishing.html:120
+#: warehouse/templates/manage/project/publishing.html:88
msgid "The filename of the publishing workflow"
msgstr ""
-#: warehouse/templates/manage/project/publishing.html:127
-#: warehouse/templates/manage/project/roles.html:320
-#: warehouse/templates/manage/team/roles.html:123
-msgid "Add"
-msgstr ""
-
-#: warehouse/templates/manage/project/publishing.html:131
+#: warehouse/templates/manage/project/publishing.html:99
msgid "Manage current providers"
msgstr ""
-#: warehouse/templates/manage/project/publishing.html:135
+#: warehouse/templates/manage/project/publishing.html:103
#, python-format
msgid "OpenID Connect publishers associated with %(project_name)s"
msgstr ""
-#: warehouse/templates/manage/project/publishing.html:139
-msgid "Publisher"
-msgstr ""
-
-#: warehouse/templates/manage/project/publishing.html:140
+#: warehouse/templates/manage/project/publishing.html:108
msgid "URL"
msgstr ""
-#: warehouse/templates/manage/project/publishing.html:151
-msgid "No publishers are currently configured."
-msgstr ""
-
#: warehouse/templates/manage/project/release.html:18
#, python-format
msgid "Manage '%(project_name)s' – release version %(version)s"
@@ -5231,7 +5342,7 @@ msgid "Project Roles"
msgstr ""
#: warehouse/templates/manage/project/roles.html:46
-#: warehouse/templates/pages/help.html:581
+#: warehouse/templates/pages/help.html:599
msgid "There are two possible roles for collaborators:"
msgstr ""
@@ -5970,186 +6081,190 @@ msgstr ""
msgid "How do I change my PyPI username?"
msgstr ""
-#: warehouse/templates/pages/help.html:71
-msgid "How can I run a mirror of PyPI?"
+#: warehouse/templates/pages/help.html:70
+msgid "How can I use OpenID connect to authenticate with PyPI?"
msgstr ""
#: warehouse/templates/pages/help.html:72
-msgid "Does PyPI have APIs I can use?"
+msgid "How can I run a mirror of PyPI?"
msgstr ""
#: warehouse/templates/pages/help.html:73
-msgid "How do I get notified when a new version of a project is released?"
+msgid "Does PyPI have APIs I can use?"
msgstr ""
#: warehouse/templates/pages/help.html:74
+msgid "How do I get notified when a new version of a project is released?"
+msgstr ""
+
+#: warehouse/templates/pages/help.html:75
msgid ""
"Where can I see statistics about PyPI, downloads, and project/package "
"usage?"
msgstr ""
-#: warehouse/templates/pages/help.html:75
+#: warehouse/templates/pages/help.html:76
msgid "What are the file hashes used for, and how can I verify them?"
msgstr ""
-#: warehouse/templates/pages/help.html:77
+#: warehouse/templates/pages/help.html:78
msgid "I forgot my PyPI password. Can you help me?"
msgstr ""
-#: warehouse/templates/pages/help.html:78
+#: warehouse/templates/pages/help.html:79
msgid "I've lost access to my PyPI account. Can you help me?"
msgstr ""
-#: warehouse/templates/pages/help.html:79
+#: warehouse/templates/pages/help.html:80
msgid ""
"Why am I getting a \"Invalid or non-existent authentication "
"information.\" error when uploading files?"
msgstr ""
-#: warehouse/templates/pages/help.html:80
+#: warehouse/templates/pages/help.html:81
msgid ""
"Why am I getting \"No matching distribution found\" or \"Could not fetch "
"URL\" errors during pip install
?"
msgstr ""
-#: warehouse/templates/pages/help.html:81
+#: warehouse/templates/pages/help.html:82
msgid "I am having trouble using the PyPI website. Can you help me?"
msgstr ""
-#: warehouse/templates/pages/help.html:82
+#: warehouse/templates/pages/help.html:83
msgid "Why can't I manually upload files to PyPI, through the browser interface?"
msgstr ""
-#: warehouse/templates/pages/help.html:83
+#: warehouse/templates/pages/help.html:84
msgid "How can I publish my private packages to PyPI?"
msgstr ""
-#: warehouse/templates/pages/help.html:84
+#: warehouse/templates/pages/help.html:85
msgid "Why did my package or user registration get blocked?"
msgstr ""
-#: warehouse/templates/pages/help.html:85
+#: warehouse/templates/pages/help.html:86
msgid "How do I get a file size limit exemption or increase for my project?"
msgstr ""
-#: warehouse/templates/pages/help.html:86
+#: warehouse/templates/pages/help.html:87
msgid ""
"How do I get a total project size limit exemption or increase for my "
"project?"
msgstr ""
-#: warehouse/templates/pages/help.html:87
+#: warehouse/templates/pages/help.html:88
msgid ""
"Where does PyPI get its data on project vulnerabilities from, and how can"
" I correct it?"
msgstr ""
-#: warehouse/templates/pages/help.html:88
+#: warehouse/templates/pages/help.html:89
msgid "Why am I getting \"the description failed to render\" error?"
msgstr ""
-#: warehouse/templates/pages/help.html:90
+#: warehouse/templates/pages/help.html:91
msgid ""
"Why am I getting a \"Filename or contents already exists\" or \"Filename "
"has been previously used\" error?"
msgstr ""
-#: warehouse/templates/pages/help.html:91
+#: warehouse/templates/pages/help.html:92
msgid "Why isn't my desired project name available?"
msgstr ""
-#: warehouse/templates/pages/help.html:92
+#: warehouse/templates/pages/help.html:93
msgid "How do I claim an abandoned or previously registered project name?"
msgstr ""
-#: warehouse/templates/pages/help.html:93
+#: warehouse/templates/pages/help.html:94
msgid "What collaborator roles are available for a project on PyPI?"
msgstr ""
-#: warehouse/templates/pages/help.html:94
+#: warehouse/templates/pages/help.html:95
msgid "How do I become an owner/maintainer of a project on PyPI?"
msgstr ""
-#: warehouse/templates/pages/help.html:95
+#: warehouse/templates/pages/help.html:96
msgid "How can I upload a project description in a different format?"
msgstr ""
-#: warehouse/templates/pages/help.html:96
+#: warehouse/templates/pages/help.html:97
msgid "How do I request a new trove classifier?"
msgstr ""
-#: warehouse/templates/pages/help.html:97
+#: warehouse/templates/pages/help.html:98
msgid "Where can I report a bug or provide feedback about PyPI?"
msgstr ""
-#: warehouse/templates/pages/help.html:99
+#: warehouse/templates/pages/help.html:100
msgid "Who maintains PyPI?"
msgstr ""
-#: warehouse/templates/pages/help.html:100
+#: warehouse/templates/pages/help.html:101
msgid "What powers PyPI?"
msgstr ""
-#: warehouse/templates/pages/help.html:101
+#: warehouse/templates/pages/help.html:102
msgid "Can I depend on PyPI being available?"
msgstr ""
-#: warehouse/templates/pages/help.html:102
+#: warehouse/templates/pages/help.html:103
msgid "How can I contribute to PyPI?"
msgstr ""
-#: warehouse/templates/pages/help.html:103
+#: warehouse/templates/pages/help.html:104
msgid "How do I keep up with upcoming changes to PyPI?"
msgstr ""
-#: warehouse/templates/pages/help.html:104
+#: warehouse/templates/pages/help.html:105
msgid "How can I get a list of PyPI's IP addresses?"
msgstr ""
-#: warehouse/templates/pages/help.html:105
+#: warehouse/templates/pages/help.html:106
msgid ""
"What does the \"beta feature\" badge mean? What are Warehouse's current "
"beta features?"
msgstr ""
-#: warehouse/templates/pages/help.html:106
+#: warehouse/templates/pages/help.html:107
msgid "How do I pronounce \"PyPI\"?"
msgstr ""
-#: warehouse/templates/pages/help.html:113
+#: warehouse/templates/pages/help.html:114
msgid "Common questions"
msgstr ""
-#: warehouse/templates/pages/help.html:116
-#: warehouse/templates/pages/help.html:202
+#: warehouse/templates/pages/help.html:117
+#: warehouse/templates/pages/help.html:204
msgid "Basics"
msgstr ""
-#: warehouse/templates/pages/help.html:127
+#: warehouse/templates/pages/help.html:128
msgid "My Account"
msgstr ""
-#: warehouse/templates/pages/help.html:145
-#: warehouse/templates/pages/help.html:526
+#: warehouse/templates/pages/help.html:147
+#: warehouse/templates/pages/help.html:544
msgid "Integrating"
msgstr ""
-#: warehouse/templates/pages/help.html:156
-#: warehouse/templates/pages/help.html:563
+#: warehouse/templates/pages/help.html:158
+#: warehouse/templates/pages/help.html:581
msgid "Administration of projects on PyPI"
msgstr ""
-#: warehouse/templates/pages/help.html:171
-#: warehouse/templates/pages/help.html:647
+#: warehouse/templates/pages/help.html:173
+#: warehouse/templates/pages/help.html:665
msgid "Troubleshooting"
msgstr ""
-#: warehouse/templates/pages/help.html:188
-#: warehouse/templates/pages/help.html:815
+#: warehouse/templates/pages/help.html:190
+#: warehouse/templates/pages/help.html:833
msgid "About"
msgstr ""
-#: warehouse/templates/pages/help.html:205
+#: warehouse/templates/pages/help.html:207
#, python-format
msgid ""
"
All PyPI user events are stored under security history in account " @@ -6287,7 +6402,7 @@ msgid "" "href=\"mailto:%(admin_email)s\">%(admin_email)s
" msgstr "" -#: warehouse/templates/pages/help.html:302 +#: warehouse/templates/pages/help.html:304 msgid "" "A PyPI API token linked to your account was posted on a public " "website. It was automatically revoked, but before regenerating a new one," @@ -6296,7 +6411,7 @@ msgid "" "applies too.
" msgstr "" -#: warehouse/templates/pages/help.html:312 +#: warehouse/templates/pages/help.html:314 #, python-format msgid "" "Two factor authentication (2FA) makes your account more secure by " @@ -6315,7 +6430,7 @@ msgid "" "rel=\"noopener\">discuss.python.org.
" msgstr "" -#: warehouse/templates/pages/help.html:339 +#: warehouse/templates/pages/help.html:341 #, python-format msgid "" "PyPI users can set up two-factor authentication using any authentication " @@ -6324,21 +6439,21 @@ msgid "" "password\">TOTP standard." msgstr "" -#: warehouse/templates/pages/help.html:340 +#: warehouse/templates/pages/help.html:342 msgid "" "TOTP authentication " "applications generate a regularly changing authentication code to use " "when logging into your account." msgstr "" -#: warehouse/templates/pages/help.html:341 +#: warehouse/templates/pages/help.html:343 msgid "" "Because TOTP is an " "open standard, there are many applications that are compatible with your " "PyPI account. Popular applications include:" msgstr "" -#: warehouse/templates/pages/help.html:344 +#: warehouse/templates/pages/help.html:346 #, python-format msgid "" "Google Authenticator for iOS" msgstr "" -#: warehouse/templates/pages/help.html:347 #: warehouse/templates/pages/help.html:349 -#: warehouse/templates/pages/help.html:354 +#: warehouse/templates/pages/help.html:351 #: warehouse/templates/pages/help.html:356 +#: warehouse/templates/pages/help.html:358 msgid "(proprietary)" msgstr "" -#: warehouse/templates/pages/help.html:351 +#: warehouse/templates/pages/help.html:353 #, python-format msgid "" "Duo Mobile for iOS" msgstr "" -#: warehouse/templates/pages/help.html:357 -#: warehouse/templates/pages/help.html:358 +#: warehouse/templates/pages/help.html:359 +#: warehouse/templates/pages/help.html:360 msgid "(open source)" msgstr "" -#: warehouse/templates/pages/help.html:362 +#: warehouse/templates/pages/help.html:364 #, python-format msgid "" "Some password managers (e.g. 2FA with an " "authentication application:" msgstr "" -#: warehouse/templates/pages/help.html:372 +#: warehouse/templates/pages/help.html:374 msgid "" "Open an authentication (TOTP) application" msgstr "" -#: warehouse/templates/pages/help.html:373 +#: warehouse/templates/pages/help.html:375 msgid "" "Log in to your PyPI account, go to your account settings, and choose " "\"Add 2FA with " "authentication application\"" msgstr "" -#: warehouse/templates/pages/help.html:374 +#: warehouse/templates/pages/help.html:376 msgid "" "PyPI will generate a secret key, specific to your account. This is " "displayed as a QR code, and as a text code." msgstr "" -#: warehouse/templates/pages/help.html:375 +#: warehouse/templates/pages/help.html:377 msgid "" "Scan the QR code with your authentication application, or type it in " "manually. The method of input will depend on the application you have " "chosen." msgstr "" -#: warehouse/templates/pages/help.html:376 +#: warehouse/templates/pages/help.html:378 msgid "" "Your application will generate an authentication code - use this to " "verify your set up on PyPI" msgstr "" -#: warehouse/templates/pages/help.html:379 +#: warehouse/templates/pages/help.html:381 msgid "" "The PyPI server and your application now share your PyPI secret key, " "allowing your application to generate valid authentication codes for your" " PyPI account." msgstr "" -#: warehouse/templates/pages/help.html:381 -#: warehouse/templates/pages/help.html:423 +#: warehouse/templates/pages/help.html:383 +#: warehouse/templates/pages/help.html:425 msgid "Next time you log in to PyPI you'll need to:" msgstr "" -#: warehouse/templates/pages/help.html:383 -#: warehouse/templates/pages/help.html:475 +#: warehouse/templates/pages/help.html:385 +#: warehouse/templates/pages/help.html:477 msgid "Provide your username and password, as normal" msgstr "" -#: warehouse/templates/pages/help.html:384 +#: warehouse/templates/pages/help.html:386 msgid "Open your authentication application to generate an authentication code" msgstr "" -#: warehouse/templates/pages/help.html:385 +#: warehouse/templates/pages/help.html:387 msgid "Use this code to finish logging into PyPI" msgstr "" -#: warehouse/templates/pages/help.html:391 +#: warehouse/templates/pages/help.html:393 msgid "" "A security device is a USB key or other " "device that generates a one-time password and sends that password to " @@ -6448,11 +6563,11 @@ msgid "" "user." msgstr "" -#: warehouse/templates/pages/help.html:393 +#: warehouse/templates/pages/help.html:395 msgid "To set up two factor authentication with a USB key, you'll need:" msgstr "" -#: warehouse/templates/pages/help.html:395 +#: warehouse/templates/pages/help.html:397 #, python-format msgid "" "To use a :" msgstr "" -#: warehouse/templates/pages/help.html:400 +#: warehouse/templates/pages/help.html:402 #, python-format msgid "" "Popular keys include Thetis." msgstr "" -#: warehouse/templates/pages/help.html:407 +#: warehouse/templates/pages/help.html:409 msgid "" "Note that some older Yubico USB keys do not follow the FIDO " "specification, and will therefore not work with PyPI" msgstr "" -#: warehouse/templates/pages/help.html:412 +#: warehouse/templates/pages/help.html:414 msgid "Follow these steps:" msgstr "" -#: warehouse/templates/pages/help.html:414 +#: warehouse/templates/pages/help.html:416 msgid "" "__token__
"
msgstr ""
-#: warehouse/templates/pages/help.html:501
+#: warehouse/templates/pages/help.html:503
msgid ""
"Set your password to the token value, including the pypi-
"
"prefix"
msgstr ""
-#: warehouse/templates/pages/help.html:505
+#: warehouse/templates/pages/help.html:507
#, python-format
msgid ""
"Where you edit or add these values will depend on your individual use "
@@ -6659,14 +6774,14 @@ msgid ""
"rel=\"noopener\">.travis.yml
if you are using Travis)."
msgstr ""
-#: warehouse/templates/pages/help.html:509
+#: warehouse/templates/pages/help.html:511
msgid ""
"Advanced users may wish to inspect their token by decoding it with "
"base64, and checking the output against the unique identifier displayed "
"on PyPI."
msgstr ""
-#: warehouse/templates/pages/help.html:513
+#: warehouse/templates/pages/help.html:515
msgid ""
"PyPI asks you to confirm your password before you want to perform a " "sensitive action. Sensitive actions include things like adding or " @@ -6677,26 +6792,43 @@ msgid "" "actions on your personal, password-protected computer.
" msgstr "" -#: warehouse/templates/pages/help.html:520 +#: warehouse/templates/pages/help.html:522 msgid "PyPI does not currently support changing a username." msgstr "" -#: warehouse/templates/pages/help.html:521 +#: warehouse/templates/pages/help.html:523 msgid "" "Instead, you can create a new account with the desired username, add the " "new account as a maintainer of all the projects your old account owns, " "and then delete the old account, which will have the same effect." msgstr "" -#: warehouse/templates/pages/help.html:529 +#: warehouse/templates/pages/help.html:527 +#, python-format +msgid "" +"PyPI users and projects can use OpenID Connect" +" (OIDC) to delegate publishing authority for a PyPI package to a trusted " +"third party service, eliminating the need to use API tokens or passwords." +msgstr "" + +#: warehouse/templates/pages/help.html:534 +msgid "" +"Using OIDC to authenticate when publishing is done by registering " +"\"providers,\" which correspond to services with OIDC identities like " +"GitHub Actions. Existing projects can add or remove OIDC providers at any" +" time. OIDC providers can also be created in advance for projects that " +"don't exist yet." +msgstr "" + +#: warehouse/templates/pages/help.html:547 msgid "Yes, including RSS feeds of new packages and new releases." msgstr "" -#: warehouse/templates/pages/help.html:529 +#: warehouse/templates/pages/help.html:547 msgid "See the API reference." msgstr "" -#: warehouse/templates/pages/help.html:532 +#: warehouse/templates/pages/help.html:550 #, python-format msgid "" "If you need to run your own mirror of PyPI, the GitHub apps." msgstr "" -#: warehouse/templates/pages/help.html:538 +#: warehouse/templates/pages/help.html:556 #, python-format msgid "" "You can analyze PyPI project/package metadata and via our public dataset on Google BigQuery." msgstr "" -#: warehouse/templates/pages/help.html:540 +#: warehouse/templates/pages/help.html:558 #, python-format msgid "" "other relevant factors." msgstr "" -#: warehouse/templates/pages/help.html:549 +#: warehouse/templates/pages/help.html:567 #, python-format msgid "" "For recent statistics on uptime and performance, see ." msgstr "" -#: warehouse/templates/pages/help.html:552 +#: warehouse/templates/pages/help.html:570 msgid "" "For each package hosted on PyPI, there are corresponding hashes for that " "file. These hashes can be used to verify that the file you are " @@ -6757,7 +6889,7 @@ msgid "" "from the JSON API. Here is an example of generating the hashes:" msgstr "" -#: warehouse/templates/pages/help.html:559 +#: warehouse/templates/pages/help.html:577 msgid "" "In practice, it would only be necessary to verify one of the hashes. It " "is not recommended to use the MD5 hash because of known security issues " @@ -6765,7 +6897,7 @@ msgid "" " only." msgstr "" -#: warehouse/templates/pages/help.html:566 +#: warehouse/templates/pages/help.html:584 #, python-format msgid "" "PyPI does not support publishing private packages. If you need to publish" @@ -6773,7 +6905,7 @@ msgid "" "run your own deployment of the devpi project." msgstr "" -#: warehouse/templates/pages/help.html:569 +#: warehouse/templates/pages/help.html:587 msgid "" "Your publishing tool may return an error that your new project can't be " "created with your desired name, despite no evidence of a project or " @@ -6781,7 +6913,7 @@ msgid "" "reasons this may occur:" msgstr "" -#: warehouse/templates/pages/help.html:571 +#: warehouse/templates/pages/help.html:589 #, python-format msgid "" "The project name conflicts with a module from any major version from 2.5 to present." msgstr "" -#: warehouse/templates/pages/help.html:572 +#: warehouse/templates/pages/help.html:590 msgid "" "The project name is too similar to an existing project and may be " "confusable." msgstr "" -#: warehouse/templates/pages/help.html:573 +#: warehouse/templates/pages/help.html:591 #, python-format msgid "" "The project name has been explicitly prohibited by the PyPI " @@ -6804,18 +6936,18 @@ msgid "" "with a malicious package." msgstr "" -#: warehouse/templates/pages/help.html:574 +#: warehouse/templates/pages/help.html:592 msgid "" "The project name has been registered by another user, but no releases " "have been created." msgstr "" -#: warehouse/templates/pages/help.html:574 +#: warehouse/templates/pages/help.html:592 #, python-format msgid "See %(anchor_text)s" msgstr "" -#: warehouse/templates/pages/help.html:578 +#: warehouse/templates/pages/help.html:596 #, python-format msgid "" "Follow the PEP 541." msgstr "" -#: warehouse/templates/pages/help.html:582 +#: warehouse/templates/pages/help.html:600 msgid "" "Can upload releases for a package. Cannot add collaborators. Cannot " "delete files, releases, or the project." msgstr "" -#: warehouse/templates/pages/help.html:583 +#: warehouse/templates/pages/help.html:601 msgid "Owner:" msgstr "" -#: warehouse/templates/pages/help.html:583 +#: warehouse/templates/pages/help.html:601 msgid "" "Can upload releases. Can add other collaborators. Can delete files, " "releases, or the entire project." msgstr "" -#: warehouse/templates/pages/help.html:586 +#: warehouse/templates/pages/help.html:604 msgid "" "Only the current owners of a project have the ability to add new owners " "or maintainers. If you need to request ownership, you should contact the " @@ -6848,12 +6980,12 @@ msgid "" "project page." msgstr "" -#: warehouse/templates/pages/help.html:587 +#: warehouse/templates/pages/help.html:605 #, python-format msgid "If the owner is unresponsive, see %(anchor_text)s" msgstr "" -#: warehouse/templates/pages/help.html:590 +#: warehouse/templates/pages/help.html:608 #, python-format msgid "" "By default, an upload's description will render with file an issue and tell us:" msgstr "" -#: warehouse/templates/pages/help.html:605 -#: warehouse/templates/pages/help.html:626 +#: warehouse/templates/pages/help.html:623 +#: warehouse/templates/pages/help.html:644 msgid "A link to your project on PyPI (or Test PyPI)" msgstr "" -#: warehouse/templates/pages/help.html:606 +#: warehouse/templates/pages/help.html:624 msgid "The size of your release, in megabytes" msgstr "" -#: warehouse/templates/pages/help.html:607 +#: warehouse/templates/pages/help.html:625 msgid "Which index/indexes you need the increase for (PyPI, Test PyPI, or both)" msgstr "" -#: warehouse/templates/pages/help.html:608 -#: warehouse/templates/pages/help.html:628 +#: warehouse/templates/pages/help.html:626 +#: warehouse/templates/pages/help.html:646 msgid "" "A brief description of your project, including the reason for the " "additional size." msgstr "" -#: warehouse/templates/pages/help.html:614 +#: warehouse/templates/pages/help.html:632 msgid "" "If you can't upload your project's release to PyPI because you're hitting" " the project size limit, first remove any unnecessary releases or " "individual files to lower your overall project size." msgstr "" -#: warehouse/templates/pages/help.html:621 +#: warehouse/templates/pages/help.html:639 #, python-format msgid "" "If that is not possible, we can sometimes increase your limit. File an issue and tell us:" msgstr "" -#: warehouse/templates/pages/help.html:627 +#: warehouse/templates/pages/help.html:645 msgid "The total size of your project, in gigabytes" msgstr "" -#: warehouse/templates/pages/help.html:634 +#: warehouse/templates/pages/help.html:652 #, python-format msgid "" "PyPI receives reports on vulnerabilities in the packages hosted on it " @@ -6935,7 +7067,7 @@ msgid "" "Advisory Database." msgstr "" -#: warehouse/templates/pages/help.html:639 +#: warehouse/templates/pages/help.html:657 #, python-format msgid "" "If you believe vulnerability data for your project is invalid or " @@ -6943,7 +7075,7 @@ msgid "" "target=\"_blank\" rel=\"noopener\">file an issue with details." msgstr "" -#: warehouse/templates/pages/help.html:651 +#: warehouse/templates/pages/help.html:669 #, python-format msgid "" "PyPI will reject uploads if the package description fails to render. You " @@ -6951,41 +7083,41 @@ msgid "" "command to locally check a description for validity." msgstr "" -#: warehouse/templates/pages/help.html:657 +#: warehouse/templates/pages/help.html:675 msgid "" "If you've forgotten your PyPI password but you remember your email " "address or username, follow these steps to reset your password:" msgstr "" -#: warehouse/templates/pages/help.html:659 +#: warehouse/templates/pages/help.html:677 #, python-format msgid "Go to reset your password." msgstr "" -#: warehouse/templates/pages/help.html:660 +#: warehouse/templates/pages/help.html:678 msgid "Enter the email address or username you used for PyPI and submit the form." msgstr "" -#: warehouse/templates/pages/help.html:661 +#: warehouse/templates/pages/help.html:679 msgid "You'll receive an email with a password reset link." msgstr "" -#: warehouse/templates/pages/help.html:666 +#: warehouse/templates/pages/help.html:684 msgid "If you've lost access to your PyPI account due to:" msgstr "" -#: warehouse/templates/pages/help.html:668 +#: warehouse/templates/pages/help.html:686 msgid "Lost access to the email address associated with your account" msgstr "" -#: warehouse/templates/pages/help.html:669 +#: warehouse/templates/pages/help.html:687 msgid "" "Lost two factor authentication application, device, and recovery " "codes" msgstr "" -#: warehouse/templates/pages/help.html:672 +#: warehouse/templates/pages/help.html:690 #, python-format msgid "" "You can proceed to API Token for uploads:" msgstr "" -#: warehouse/templates/pages/help.html:686 +#: warehouse/templates/pages/help.html:704 msgid "Ensure that your API Token is valid and has not been revoked." msgstr "" -#: warehouse/templates/pages/help.html:687 +#: warehouse/templates/pages/help.html:705 msgid "" "Ensure that your API Token is properly " "formatted and does not contain any trailing characters such as " "newlines." msgstr "" -#: warehouse/templates/pages/help.html:688 +#: warehouse/templates/pages/help.html:706 msgid "Ensure that the username you are using is__token__
."
msgstr ""
-#: warehouse/templates/pages/help.html:690
+#: warehouse/templates/pages/help.html:708
msgid ""
"In both cases, remember that PyPI and TestPyPI each require you to create"
" an account, so your credentials may be different."
msgstr ""
-#: warehouse/templates/pages/help.html:692
+#: warehouse/templates/pages/help.html:710
msgid ""
"If you're using Windows and trying to paste your password or token in the"
" Command Prompt or PowerShell, note that Ctrl-V and Shift+Insert won't "
@@ -7040,7 +7172,7 @@ msgid ""
"enable \"Use Ctrl+Shift+C/V as Copy/Paste\" in \"Properties\"."
msgstr ""
-#: warehouse/templates/pages/help.html:696
+#: warehouse/templates/pages/help.html:714
#, python-format
msgid ""
"This is a Learn why on the PSF blog."
msgstr ""
-#: warehouse/templates/pages/help.html:710
+#: warehouse/templates/pages/help.html:728
#, python-format
msgid ""
"If you are having trouble with %(command)s
and get a "
@@ -7069,7 +7201,7 @@ msgid ""
"information:"
msgstr ""
-#: warehouse/templates/pages/help.html:712
+#: warehouse/templates/pages/help.html:730
msgid ""
"If you see an error like There was a problem confirming the ssl "
"certificate
or tlsv1 alert protocol version
or "
@@ -7077,7 +7209,7 @@ msgid ""
"PyPI with a newer TLS support library."
msgstr ""
-#: warehouse/templates/pages/help.html:713
+#: warehouse/templates/pages/help.html:731
msgid ""
"The specific steps you need to take will depend on your operating system "
"version, where your installation of Python originated (python.org, your "
@@ -7085,7 +7217,7 @@ msgid ""
" Python, setuptools
, and pip
."
msgstr ""
-#: warehouse/templates/pages/help.html:715
+#: warehouse/templates/pages/help.html:733
#, python-format
msgid ""
"For help, go to %(command)s."
msgstr ""
-#: warehouse/templates/pages/help.html:726
+#: warehouse/templates/pages/help.html:744
#, python-format
msgid ""
"We take , so we can try to fix the problem, for you and others."
msgstr ""
-#: warehouse/templates/pages/help.html:739
+#: warehouse/templates/pages/help.html:757
#, python-format
msgid ""
"In a previous version of PyPI, it used to be possible for maintainers to "
@@ -7124,7 +7256,7 @@ msgid ""
"rel=\"noopener\">use twine to upload your project to PyPI."
msgstr ""
-#: warehouse/templates/pages/help.html:748
+#: warehouse/templates/pages/help.html:766
msgid ""
"Spammers return to PyPI with some regularity hoping to place their Search"
" Engine Optimized phishing, scam, and click-farming content on the site. "
@@ -7133,7 +7265,7 @@ msgid ""
"prime target."
msgstr ""
-#: warehouse/templates/pages/help.html:750
+#: warehouse/templates/pages/help.html:768
#, python-format
msgid ""
"When the PyPI administrators are overwhelmed by spam or "
@@ -7144,35 +7276,35 @@ msgid ""
"have updated it with reasoning for the intervention."
msgstr ""
-#: warehouse/templates/pages/help.html:759
+#: warehouse/templates/pages/help.html:777
msgid "PyPI will return these errors for one of these reasons:"
msgstr ""
-#: warehouse/templates/pages/help.html:761
+#: warehouse/templates/pages/help.html:779
msgid "Filename has been used and file exists"
msgstr ""
-#: warehouse/templates/pages/help.html:762
+#: warehouse/templates/pages/help.html:780
msgid "Filename has been used but file no longer exists"
msgstr ""
-#: warehouse/templates/pages/help.html:763
+#: warehouse/templates/pages/help.html:781
msgid "A file with the exact same content exists"
msgstr ""
-#: warehouse/templates/pages/help.html:766
+#: warehouse/templates/pages/help.html:784
msgid ""
"PyPI does not allow for a filename to be reused, even once a project has "
"been deleted and recreated."
msgstr ""
-#: warehouse/templates/pages/help.html:772
+#: warehouse/templates/pages/help.html:790
msgid ""
"A distribution filename on PyPI consists of the combination of project "
"name, version number, and distribution type."
msgstr ""
-#: warehouse/templates/pages/help.html:778
+#: warehouse/templates/pages/help.html:796
msgid ""
"This ensures that a given distribution for a given release for a given "
"project will always resolve to the same file, and cannot be "
@@ -7180,14 +7312,14 @@ msgid ""
" party (it can only be removed)."
msgstr ""
-#: warehouse/templates/pages/help.html:786
+#: warehouse/templates/pages/help.html:804
msgid ""
"To avoid this situation in most cases, you will need to change the "
"version number to one that you haven't previously uploaded to PyPI, "
"rebuild the distribution, and then upload the new distribution."
msgstr ""
-#: warehouse/templates/pages/help.html:795
+#: warehouse/templates/pages/help.html:813
#, python-format
msgid ""
"If you would like to request a new trove classifier file a pull request "
@@ -7196,7 +7328,7 @@ msgid ""
" to include a brief justification of why it is important."
msgstr ""
-#: warehouse/templates/pages/help.html:803
+#: warehouse/templates/pages/help.html:821
#, python-format
msgid ""
"If you're experiencing an issue with PyPI itself, we welcome "
@@ -7207,14 +7339,14 @@ msgid ""
" first check that a similar issue does not already exist."
msgstr ""
-#: warehouse/templates/pages/help.html:810
+#: warehouse/templates/pages/help.html:828
msgid ""
"If you are having an issue is with a specific package installed from "
"PyPI, you should reach out to the maintainers of that project directly "
"instead."
msgstr ""
-#: warehouse/templates/pages/help.html:819
+#: warehouse/templates/pages/help.html:837
#, python-format
msgid ""
"PyPI is powered by the Warehouse project; ."
msgstr ""
-#: warehouse/templates/pages/help.html:846
+#: warehouse/templates/pages/help.html:864
msgid ""
"As of April 16, 2018, PyPI.org is at \"production\" status, meaning that "
"it has moved out of beta and replaced the old site (pypi.python.org). It "
"is now robust, tested, and ready for expected browser and API traffic."
msgstr ""
-#: warehouse/templates/pages/help.html:848
+#: warehouse/templates/pages/help.html:866
#, python-format
msgid ""
"PyPI is heavily cached and distributed via private index."
msgstr ""
-#: warehouse/templates/pages/help.html:862
+#: warehouse/templates/pages/help.html:880
#, python-format
msgid ""
"We have a huge amount of work to do to continue to maintain and improve "
@@ -7287,22 +7419,22 @@ msgid ""
"target=\"_blank\" rel=\"noopener\">the Warehouse project)."
msgstr ""
-#: warehouse/templates/pages/help.html:867
+#: warehouse/templates/pages/help.html:885
msgid "Financial:"
msgstr ""
-#: warehouse/templates/pages/help.html:867
+#: warehouse/templates/pages/help.html:885
#, python-format
msgid ""
"We would deeply appreciate your donations to fund "
"development and maintenance."
msgstr ""
-#: warehouse/templates/pages/help.html:868
+#: warehouse/templates/pages/help.html:886
msgid "Development:"
msgstr ""
-#: warehouse/templates/pages/help.html:868
+#: warehouse/templates/pages/help.html:886
msgid ""
"Warehouse is open source, and we would love to see some new faces working"
" on the project. You do not need to be an experienced "
@@ -7310,7 +7442,7 @@ msgid ""
" you make your first open source pull request!"
msgstr ""
-#: warehouse/templates/pages/help.html:870
+#: warehouse/templates/pages/help.html:888
#, python-format
msgid ""
"If you have skills in Python, ElasticSearch, HTML, SCSS, JavaScript, or "
@@ -7324,7 +7456,7 @@ msgid ""
"here."
msgstr ""
-#: warehouse/templates/pages/help.html:878
+#: warehouse/templates/pages/help.html:896
#, python-format
msgid ""
"Issues are grouped into Python packaging forum on Discourse."
msgstr ""
-#: warehouse/templates/pages/help.html:895
+#: warehouse/templates/pages/help.html:913
#, python-format
msgid ""
"Changes to PyPI are generally announced on both the %(href)s."
msgstr ""
-#: warehouse/templates/pages/help.html:906
+#: warehouse/templates/pages/help.html:924
#, python-format
msgid ""
"More information about this list can be found here: %(href)s."
msgstr ""
-#: warehouse/templates/pages/help.html:910
+#: warehouse/templates/pages/help.html:928
msgid ""
"When Warehouse's maintainers are deploying new features, at first we mark"
" them with a small \"beta feature\" symbol to tell you: this should "
@@ -7382,11 +7514,11 @@ msgid ""
"functionality."
msgstr ""
-#: warehouse/templates/pages/help.html:911
+#: warehouse/templates/pages/help.html:929
msgid "Currently, no features are in beta."
msgstr ""
-#: warehouse/templates/pages/help.html:915
+#: warehouse/templates/pages/help.html:933
#, python-format
msgid ""
"\"PyPI\" should be pronounced like \"pie pea eye\", specifically with the"
@@ -7396,39 +7528,39 @@ msgid ""
"implementation of the Python language."
msgstr ""
-#: warehouse/templates/pages/help.html:927
+#: warehouse/templates/pages/help.html:945
msgid "Resources"
msgstr ""
-#: warehouse/templates/pages/help.html:928
+#: warehouse/templates/pages/help.html:946
msgid "Looking for something else? Perhaps these links will help:"
msgstr ""
-#: warehouse/templates/pages/help.html:930
+#: warehouse/templates/pages/help.html:948
msgid "Python Packaging User Guide"
msgstr ""
-#: warehouse/templates/pages/help.html:931
+#: warehouse/templates/pages/help.html:949
msgid "Python documentation"
msgstr ""
-#: warehouse/templates/pages/help.html:932
+#: warehouse/templates/pages/help.html:950
msgid "(main Python website)"
msgstr ""
-#: warehouse/templates/pages/help.html:933
+#: warehouse/templates/pages/help.html:951
msgid "Python community page"
msgstr ""
-#: warehouse/templates/pages/help.html:933
+#: warehouse/templates/pages/help.html:951
msgid "(lists IRC channels, mailing lists, etc.)"
msgstr ""
-#: warehouse/templates/pages/help.html:936
+#: warehouse/templates/pages/help.html:954
msgid "Contact"
msgstr ""
-#: warehouse/templates/pages/help.html:938
+#: warehouse/templates/pages/help.html:956
#, python-format
msgid ""
"The {% trans %}Security history{% endtrans %}
{% elif event.tag == EventTag.Account.PasswordChange %}
{% trans %}Password successfully changed{% endtrans %}
+ {% elif event.tag == EventTag.Account.PendingOIDCProviderAdded %}
+ Pending OpenID Connect provider added
+ {% trans %}Project:{% endtrans %} {{ event.additional.project }}
+ {% trans %}Provider:{% endtrans %} {{ event.additional.provider }}
+ {% trans %}Publisher:{% endtrans %} {{ event.additional.specifier }}
+
+ {% elif event.tag == EventTag.Account.PendingOIDCProviderRemoved %}
+ Pending OpenID Connect provider removed
+ {% trans %}Project:{% endtrans %} {{ event.additional.project }}
+ {% trans %}Provider:{% endtrans %} {{ event.additional.provider }}
+ {% trans %}Publisher:{% endtrans %} {{ event.additional.specifier }}
+
{% elif event.tag == EventTag.Account.TwoFactorMethodAdded %}
{% trans %}Two factor authentication added{% endtrans %}+ {% trans %} + You can use this page to register "pending" OpenID Connect providers. + {% endtrans %} +
+ + ++ {% trans href="/help#openid-connect" %} + These providers behave similarly to OpenID Connect providers registered + against specific projects, except that they allow users to create + the project if it doesn't already exist. Once the project is created, + the "pending" provider becomes an ordinary OpenID Connect provider. + You can read more about "pending" and ordinary OpenID Connect providers + here. + {% endtrans %} +
+ +{% trans %}Pending project name{% endtrans %} | +{% trans %}Publisher{% endtrans %} | +{% trans %}Publishing workflow URL{% endtrans %} | ++ |
---|
{% trans %}No publishers are currently configured.{% endtrans %}
+ {% endif %} + +{% endblock %} diff --git a/warehouse/templates/manage/manage_base.html b/warehouse/templates/manage/manage_base.html index 270fb218f6ea..3a610313610a 100644 --- a/warehouse/templates/manage/manage_base.html +++ b/warehouse/templates/manage/manage_base.html @@ -230,6 +230,14 @@+ {% trans %} + OpenID Connect (OIDC) provides a flexible, credential-free mechanism for delegating + publishing authority for a PyPI package to a third party service, + like GitHub Actions. + {% endtrans %} +
+ ++ {% trans %} + PyPI users and projects can use trusted publishers to automate + their release processes, without needing to use API tokens or passwords. + {% endtrans %} +
+ ++ {% trans href="/help#openid-connect" %} + You can read more about OpenID Connect and how to use it + here. + {% endtrans %} +
+{% endmacro %} + +{% macro oidc_provider_row(provider) -%} +{# project_name is only defined for pending OIDC providers -#} +{% if provider.project_name is defined %} +{% set delete_route = request.route_path('manage.account.publishing') %} +{% else %} +{% set delete_route = request.route_path('manage.project.settings.publishing', project_name=project.name) %} +{% endif %} + + + +- {% trans %} - 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 %} - PyPI projects can use trusted OpenID Connect publishers to automate their release - processes, without having to explicitly provision or manage API tokens. - {% endtrans %} -
+ {{ oidc_desc() }}{% trans %}PyPI does not currently support changing a username.{% endtrans %}
{% trans %}Instead, you can create a new account with the desired username, add the new account as a maintainer of all the projects your old account owns, and then delete the old account, which will have the same effect.{% endtrans %}
++ {% trans openid="https://openid.net/connect/", apitoken="#apitoken" %} + PyPI users and projects can use OpenID Connect (OIDC) to delegate + publishing authority for a PyPI package to a trusted third party service, eliminating + the need to use API tokens or passwords. + {% endtrans %} +
++ {% trans %} + Using OIDC to authenticate when publishing is done by registering "providers," + which correspond to services with OIDC identities like GitHub Actions. + Existing projects can add or remove OIDC providers at any time. OIDC providers + can also be created in advance for projects that don't exist yet. + {% endtrans %} +
{% trans href='https://status.python.org/', title=gettext('External link') %}For recent statistics on uptime and performance, see our status page.{% endtrans %}
- +{% trans %}For each package hosted on PyPI, there are corresponding hashes for that file. These hashes can be used to verify that the file you are downloading is the same one that the project maintainer uploaded. This is especially useful if downloading packages from a mirror. The hashes can be obtained from the project page in the "Download Files" section or from the JSON API. Here is an example of generating the hashes:{% endtrans %}
import hashlib diff --git a/warehouse/utils/project.py b/warehouse/utils/project.py index b9541586921a..8316bb559cf3 100644 --- a/warehouse/utils/project.py +++ b/warehouse/utils/project.py @@ -10,6 +10,8 @@ # See the License for the specific language governing permissions and # limitations under the License. +import re + from itertools import chain import stdlib_list @@ -56,6 +58,10 @@ def _namespace_stdlib_list(module_list): ) } +PROJECT_NAME_RE = re.compile( + r"^([A-Z0-9]|[A-Z0-9][A-Z0-9._-]*[A-Z0-9])$", re.IGNORECASE +) + def validate_project_name(name, request): """