diff --git a/tests/common/db/oidc.py b/tests/common/db/oidc.py index 59b71311a106..63354349755b 100644 --- a/tests/common/db/oidc.py +++ b/tests/common/db/oidc.py @@ -15,9 +15,11 @@ from warehouse.oidc.models import ( ActiveStatePublisher, GitHubPublisher, + GitLabPublisher, GooglePublisher, PendingActiveStatePublisher, PendingGitHubPublisher, + PendingGitLabPublisher, PendingGooglePublisher, ) @@ -51,6 +53,30 @@ class Meta: added_by = factory.SubFactory(UserFactory) +class GitLabPublisherFactory(WarehouseFactory): + class Meta: + model = GitLabPublisher + + id = factory.Faker("uuid4", cast_to=None) + project = factory.Faker("pystr", max_chars=12) + namespace = factory.Faker("pystr", max_chars=12) + workflow_filepath = "subfolder/example.yml" + environment = "production" + + +class PendingGitLabPublisherFactory(WarehouseFactory): + class Meta: + model = PendingGitLabPublisher + + id = factory.Faker("uuid4", cast_to=None) + project_name = "fake-nonexistent-project" + project = factory.Faker("pystr", max_chars=12) + namespace = factory.Faker("pystr", max_chars=12) + workflow_filepath = "subfolder/example.yml" + environment = "production" + added_by = factory.SubFactory(UserFactory) + + class GooglePublisherFactory(WarehouseFactory): class Meta: model = GooglePublisher diff --git a/tests/unit/accounts/test_views.py b/tests/unit/accounts/test_views.py index b29d69ef4fe8..bc2a80abadd3 100644 --- a/tests/unit/accounts/test_views.py +++ b/tests/unit/accounts/test_views.py @@ -60,6 +60,7 @@ from warehouse.oidc.models import ( PendingActiveStatePublisher, PendingGitHubPublisher, + PendingGitLabPublisher, PendingGooglePublisher, ) from warehouse.organizations.models import ( @@ -3371,6 +3372,13 @@ def test_manage_publishing(self, metrics, monkeypatch): monkeypatch.setattr( views, "PendingGitHubPublisherForm", pending_github_publisher_form_cls ) + pending_gitlab_publisher_form_obj = pretend.stub() + pending_gitlab_publisher_form_cls = pretend.call_recorder( + lambda *a, **kw: pending_gitlab_publisher_form_obj + ) + monkeypatch.setattr( + views, "PendingGitLabPublisherForm", pending_gitlab_publisher_form_cls + ) pending_google_publisher_form_obj = pretend.stub() pending_google_publisher_form_cls = pretend.call_recorder( lambda *a, **kw: pending_google_publisher_form_obj @@ -3393,10 +3401,12 @@ def test_manage_publishing(self, metrics, monkeypatch): assert view.manage_publishing() == { "disabled": { "GitHub": False, + "GitLab": False, "Google": False, "ActiveState": False, }, "pending_github_publisher_form": pending_github_publisher_form_obj, + "pending_gitlab_publisher_form": pending_gitlab_publisher_form_obj, "pending_google_publisher_form": pending_google_publisher_form_obj, "pending_activestate_publisher_form": pending_activestate_publisher_form_obj, # noqa: E501 } @@ -3404,6 +3414,7 @@ def test_manage_publishing(self, metrics, monkeypatch): assert request.flags.disallow_oidc.calls == [ pretend.call(), pretend.call(AdminFlagValue.DISALLOW_GITHUB_OIDC), + pretend.call(AdminFlagValue.DISALLOW_GITLAB_OIDC), pretend.call(AdminFlagValue.DISALLOW_GOOGLE_OIDC), pretend.call(AdminFlagValue.DISALLOW_ACTIVESTATE_OIDC), ] @@ -3415,6 +3426,12 @@ def test_manage_publishing(self, metrics, monkeypatch): project_factory=project_factory, ) ] + assert pending_gitlab_publisher_form_cls.calls == [ + pretend.call( + request.POST, + project_factory=project_factory, + ) + ] def test_manage_publishing_admin_disabled(self, monkeypatch, pyramid_request): pyramid_request.user = pretend.stub() @@ -3441,6 +3458,13 @@ def test_manage_publishing_admin_disabled(self, monkeypatch, pyramid_request): monkeypatch.setattr( views, "PendingGitHubPublisherForm", pending_github_publisher_form_cls ) + pending_gitlab_publisher_form_obj = pretend.stub() + pending_gitlab_publisher_form_cls = pretend.call_recorder( + lambda *a, **kw: pending_gitlab_publisher_form_obj + ) + monkeypatch.setattr( + views, "PendingGitLabPublisherForm", pending_gitlab_publisher_form_cls + ) pending_google_publisher_form_obj = pretend.stub() pending_google_publisher_form_cls = pretend.call_recorder( lambda *a, **kw: pending_google_publisher_form_obj @@ -3463,10 +3487,12 @@ def test_manage_publishing_admin_disabled(self, monkeypatch, pyramid_request): assert view.manage_publishing() == { "disabled": { "GitHub": True, + "GitLab": True, "Google": True, "ActiveState": True, }, "pending_github_publisher_form": pending_github_publisher_form_obj, + "pending_gitlab_publisher_form": pending_gitlab_publisher_form_obj, "pending_google_publisher_form": pending_google_publisher_form_obj, "pending_activestate_publisher_form": pending_activestate_publisher_form_obj, # noqa: E501 } @@ -3474,6 +3500,7 @@ def test_manage_publishing_admin_disabled(self, monkeypatch, pyramid_request): assert pyramid_request.flags.disallow_oidc.calls == [ pretend.call(), pretend.call(AdminFlagValue.DISALLOW_GITHUB_OIDC), + pretend.call(AdminFlagValue.DISALLOW_GITLAB_OIDC), pretend.call(AdminFlagValue.DISALLOW_GOOGLE_OIDC), pretend.call(AdminFlagValue.DISALLOW_ACTIVESTATE_OIDC), ] @@ -3493,6 +3520,12 @@ def test_manage_publishing_admin_disabled(self, monkeypatch, pyramid_request): project_factory=project_factory, ) ] + assert pending_gitlab_publisher_form_cls.calls == [ + pretend.call( + pyramid_request.POST, + project_factory=project_factory, + ) + ] @pytest.mark.parametrize( "view_name, flag, publisher_name", @@ -3502,6 +3535,11 @@ def test_manage_publishing_admin_disabled(self, monkeypatch, pyramid_request): AdminFlagValue.DISALLOW_GITHUB_OIDC, "GitHub", ), + ( + "add_pending_gitlab_oidc_publisher", + AdminFlagValue.DISALLOW_GITLAB_OIDC, + "GitLab", + ), ( "add_pending_google_oidc_publisher", AdminFlagValue.DISALLOW_GOOGLE_OIDC, @@ -3552,6 +3590,13 @@ def test_add_pending_oidc_publisher_admin_disabled( "PendingActiveStatePublisherForm", pending_activestate_publisher_form_cls, ) + pending_gitlab_publisher_form_obj = pretend.stub() + pending_gitlab_publisher_form_cls = pretend.call_recorder( + lambda *a, **kw: pending_gitlab_publisher_form_obj + ) + monkeypatch.setattr( + views, "PendingGitLabPublisherForm", pending_gitlab_publisher_form_cls + ) pending_google_publisher_form_obj = pretend.stub() pending_google_publisher_form_cls = pretend.call_recorder( lambda *a, **kw: pending_google_publisher_form_obj @@ -3565,20 +3610,24 @@ def test_add_pending_oidc_publisher_admin_disabled( assert getattr(view, view_name)() == { "disabled": { "GitHub": True, + "GitLab": True, "Google": True, "ActiveState": True, }, "pending_github_publisher_form": pending_github_publisher_form_obj, + "pending_gitlab_publisher_form": pending_gitlab_publisher_form_obj, "pending_google_publisher_form": pending_google_publisher_form_obj, "pending_activestate_publisher_form": pending_activestate_publisher_form_obj, # noqa: E501 } assert pyramid_request.flags.disallow_oidc.calls == [ pretend.call(AdminFlagValue.DISALLOW_GITHUB_OIDC), + pretend.call(AdminFlagValue.DISALLOW_GITLAB_OIDC), pretend.call(AdminFlagValue.DISALLOW_GOOGLE_OIDC), pretend.call(AdminFlagValue.DISALLOW_ACTIVESTATE_OIDC), pretend.call(flag), pretend.call(AdminFlagValue.DISALLOW_GITHUB_OIDC), + pretend.call(AdminFlagValue.DISALLOW_GITLAB_OIDC), pretend.call(AdminFlagValue.DISALLOW_GOOGLE_OIDC), pretend.call(AdminFlagValue.DISALLOW_ACTIVESTATE_OIDC), ] @@ -3599,6 +3648,12 @@ def test_add_pending_oidc_publisher_admin_disabled( project_factory=project_factory, ) ] + assert pending_gitlab_publisher_form_cls.calls == [ + pretend.call( + pyramid_request.POST, + project_factory=project_factory, + ) + ] @pytest.mark.parametrize( "view_name, flag, publisher_name", @@ -3608,6 +3663,11 @@ def test_add_pending_oidc_publisher_admin_disabled( AdminFlagValue.DISALLOW_GITHUB_OIDC, "GitHub", ), + ( + "add_pending_gitlab_oidc_publisher", + AdminFlagValue.DISALLOW_GITLAB_OIDC, + "GitLab", + ), ( "add_pending_google_oidc_publisher", AdminFlagValue.DISALLOW_GOOGLE_OIDC, @@ -3654,6 +3714,13 @@ def test_add_pending_oidc_publisher_user_cannot_register( monkeypatch.setattr( views, "PendingGitHubPublisherForm", pending_github_publisher_form_cls ) + pending_gitlab_publisher_form_obj = pretend.stub() + pending_gitlab_publisher_form_cls = pretend.call_recorder( + lambda *a, **kw: pending_gitlab_publisher_form_obj + ) + monkeypatch.setattr( + views, "PendingGitLabPublisherForm", pending_gitlab_publisher_form_cls + ) pending_google_publisher_form_obj = pretend.stub() pending_google_publisher_form_cls = pretend.call_recorder( lambda *a, **kw: pending_google_publisher_form_obj @@ -3676,20 +3743,24 @@ def test_add_pending_oidc_publisher_user_cannot_register( assert getattr(view, view_name)() == { "disabled": { "GitHub": False, + "GitLab": False, "Google": False, "ActiveState": False, }, "pending_github_publisher_form": pending_github_publisher_form_obj, + "pending_gitlab_publisher_form": pending_gitlab_publisher_form_obj, "pending_google_publisher_form": pending_google_publisher_form_obj, "pending_activestate_publisher_form": pending_activestate_publisher_form_obj, # noqa: E501 } assert pyramid_request.flags.disallow_oidc.calls == [ pretend.call(AdminFlagValue.DISALLOW_GITHUB_OIDC), + pretend.call(AdminFlagValue.DISALLOW_GITLAB_OIDC), pretend.call(AdminFlagValue.DISALLOW_GOOGLE_OIDC), pretend.call(AdminFlagValue.DISALLOW_ACTIVESTATE_OIDC), pretend.call(flag), pretend.call(AdminFlagValue.DISALLOW_GITHUB_OIDC), + pretend.call(AdminFlagValue.DISALLOW_GITLAB_OIDC), pretend.call(AdminFlagValue.DISALLOW_GOOGLE_OIDC), pretend.call(AdminFlagValue.DISALLOW_ACTIVESTATE_OIDC), ] @@ -3716,6 +3787,12 @@ def test_add_pending_oidc_publisher_user_cannot_register( project_factory=project_factory, ) ] + assert pending_gitlab_publisher_form_cls.calls == [ + pretend.call( + pyramid_request.POST, + project_factory=project_factory, + ) + ] @pytest.mark.parametrize( "view_name, flag, publisher_name, make_publisher, publisher_class", @@ -3735,6 +3812,20 @@ def test_add_pending_oidc_publisher_user_cannot_register( ), PendingGitHubPublisher, ), + ( + "add_pending_gitlab_oidc_publisher", + AdminFlagValue.DISALLOW_GITLAB_OIDC, + "GitLab", + lambda i, user_id: PendingGitLabPublisher( + project_name="some-project-name-" + str(i), + project="some-repository" + str(i), + namespace="some-namespace", + workflow_filepath="some-filepath", + environment="", + added_by_id=user_id, + ), + PendingGitLabPublisher, + ), ( "add_pending_google_oidc_publisher", AdminFlagValue.DISALLOW_GOOGLE_OIDC, @@ -3805,13 +3896,16 @@ def test_add_pending_github_oidc_publisher_too_many_already( assert getattr(view, view_name)() == view.default_response assert db_request.flags.disallow_oidc.calls == [ pretend.call(AdminFlagValue.DISALLOW_GITHUB_OIDC), + pretend.call(AdminFlagValue.DISALLOW_GITLAB_OIDC), pretend.call(AdminFlagValue.DISALLOW_GOOGLE_OIDC), pretend.call(AdminFlagValue.DISALLOW_ACTIVESTATE_OIDC), pretend.call(flag), pretend.call(AdminFlagValue.DISALLOW_GITHUB_OIDC), + pretend.call(AdminFlagValue.DISALLOW_GITLAB_OIDC), pretend.call(AdminFlagValue.DISALLOW_GOOGLE_OIDC), pretend.call(AdminFlagValue.DISALLOW_ACTIVESTATE_OIDC), pretend.call(AdminFlagValue.DISALLOW_GITHUB_OIDC), + pretend.call(AdminFlagValue.DISALLOW_GITLAB_OIDC), pretend.call(AdminFlagValue.DISALLOW_GOOGLE_OIDC), pretend.call(AdminFlagValue.DISALLOW_ACTIVESTATE_OIDC), ] @@ -3839,6 +3933,10 @@ def test_add_pending_github_oidc_publisher_too_many_already( "add_pending_github_oidc_publisher", "GitHub", ), + ( + "add_pending_gitlab_oidc_publisher", + "GitLab", + ), ( "add_pending_google_oidc_publisher", "Google", @@ -3909,6 +4007,10 @@ def test_add_pending_oidc_publisher_ratelimited( "add_pending_github_oidc_publisher", "GitHub", ), + ( + "add_pending_gitlab_oidc_publisher", + "GitLab", + ), ( "add_pending_google_oidc_publisher", "Google", @@ -4019,6 +4121,27 @@ def test_add_pending_oidc_publisher_invalid_form( } ), ), + ( + "add_pending_gitlab_oidc_publisher", + "GitLab", + lambda user_id: PendingGitLabPublisher( + project_name="some-project-name", + namespace="some-owner", + project="some-repository", + workflow_filepath="subfolder/some-workflow-filename.yml", + environment="some-environment", + added_by_id=user_id, + ), + MultiDict( + { + "namespace": "some-owner", + "project": "some-repository", + "workflow_filepath": "subfolder/some-workflow-filename.yml", + "environment": "some-environment", + "project_name": "some-project-name", + } + ), + ), ( "add_pending_google_oidc_publisher", "Google", @@ -4155,6 +4278,20 @@ def test_add_pending_oidc_publisher_already_exists( ), PendingGitHubPublisher, ), + ( + "add_pending_gitlab_oidc_publisher", + "GitLab", + MultiDict( + { + "namespace": "some-owner", + "project": "some-repository", + "workflow_filepath": "subfolder/some-workflow-filename.yml", + "environment": "some-environment", + "project_name": "some-project-name", + } + ), + PendingGitLabPublisher, + ), ( "add_pending_google_oidc_publisher", "Google", @@ -4305,6 +4442,13 @@ def test_delete_pending_oidc_publisher_admin_disabled( monkeypatch.setattr( views, "PendingGitHubPublisherForm", pending_github_publisher_form_cls ) + pending_gitlab_publisher_form_obj = pretend.stub() + pending_gitlab_publisher_form_cls = pretend.call_recorder( + lambda *a, **kw: pending_gitlab_publisher_form_obj + ) + monkeypatch.setattr( + views, "PendingGitLabPublisherForm", pending_gitlab_publisher_form_cls + ) pending_google_publisher_form_obj = pretend.stub() pending_google_publisher_form_cls = pretend.call_recorder( lambda *a, **kw: pending_google_publisher_form_obj @@ -4327,10 +4471,12 @@ def test_delete_pending_oidc_publisher_admin_disabled( assert view.delete_pending_oidc_publisher() == { "disabled": { "GitHub": True, + "GitLab": True, "Google": True, "ActiveState": True, }, "pending_github_publisher_form": pending_github_publisher_form_obj, + "pending_gitlab_publisher_form": pending_gitlab_publisher_form_obj, "pending_google_publisher_form": pending_google_publisher_form_obj, "pending_activestate_publisher_form": pending_activestate_publisher_form_obj, # noqa: E501 } @@ -4338,6 +4484,7 @@ def test_delete_pending_oidc_publisher_admin_disabled( assert pyramid_request.flags.disallow_oidc.calls == [ pretend.call(), pretend.call(AdminFlagValue.DISALLOW_GITHUB_OIDC), + pretend.call(AdminFlagValue.DISALLOW_GITLAB_OIDC), pretend.call(AdminFlagValue.DISALLOW_GOOGLE_OIDC), pretend.call(AdminFlagValue.DISALLOW_ACTIVESTATE_OIDC), ] @@ -4357,6 +4504,12 @@ def test_delete_pending_oidc_publisher_admin_disabled( project_factory=project_factory, ) ] + assert pending_gitlab_publisher_form_cls.calls == [ + pretend.call( + pyramid_request.POST, + project_factory=project_factory, + ) + ] def test_delete_pending_oidc_publisher_invalid_form( self, monkeypatch, pyramid_request @@ -4403,6 +4556,17 @@ def test_delete_pending_oidc_publisher_invalid_form( ), PendingGitHubPublisher, ), + ( + lambda user_id: PendingGitLabPublisher( + project_name="some-project-name", + namespace="some-owner", + project="some-repository", + workflow_filepath="subfolder/some-filename", + environment="", + added_by_id=user_id, + ), + PendingGitLabPublisher, + ), ( lambda user_id: PendingGooglePublisher( project_name="some-project-name", @@ -4474,6 +4638,17 @@ def test_delete_pending_oidc_publisher_not_found( ), PendingGitHubPublisher, ), + ( + lambda user_id: PendingGitLabPublisher( + project_name="some-project-name", + namespace="some-owner", + project="some-repository", + workflow_filepath="subfolder/some-filename", + environment="", + added_by_id=user_id, + ), + PendingGitLabPublisher, + ), ( lambda user_id: PendingGooglePublisher( project_name="some-project-name", @@ -4538,6 +4713,18 @@ def test_delete_pending_oidc_publisher_no_access( ), PendingGitHubPublisher, ), + ( + "GitLab", + lambda user_id: PendingGitLabPublisher( + project_name="some-project-name", + namespace="some-owner", + project="some-owner", + workflow_filepath="subfolder/some-filename", + environment="", + added_by_id=user_id, + ), + PendingGitLabPublisher, + ), ( "Google", lambda user_id: PendingGooglePublisher( diff --git a/tests/unit/manage/test_views.py b/tests/unit/manage/test_views.py index 706b193842e3..39c50e033f54 100644 --- a/tests/unit/manage/test_views.py +++ b/tests/unit/manage/test_views.py @@ -50,6 +50,7 @@ from warehouse.oidc.models import ( ActiveStatePublisher, GitHubPublisher, + GitLabPublisher, GooglePublisher, OIDCPublisher, ) @@ -5840,9 +5841,15 @@ def test_manage_project_oidc_publishers(self, monkeypatch): view = views.ManageOIDCPublisherViews(project, request) assert view.manage_project_oidc_publishers() == { - "disabled": {"GitHub": False, "Google": False, "ActiveState": False}, + "disabled": { + "GitHub": False, + "GitLab": False, + "Google": False, + "ActiveState": False, + }, "project": project, "github_publisher_form": view.github_publisher_form, + "gitlab_publisher_form": view.gitlab_publisher_form, "google_publisher_form": view.google_publisher_form, "activestate_publisher_form": view.activestate_publisher_form, } @@ -5850,6 +5857,7 @@ def test_manage_project_oidc_publishers(self, monkeypatch): assert request.flags.disallow_oidc.calls == [ pretend.call(), pretend.call(AdminFlagValue.DISALLOW_GITHUB_OIDC), + pretend.call(AdminFlagValue.DISALLOW_GITLAB_OIDC), pretend.call(AdminFlagValue.DISALLOW_GOOGLE_OIDC), pretend.call(AdminFlagValue.DISALLOW_ACTIVESTATE_OIDC), ] @@ -5876,9 +5884,15 @@ def test_manage_project_oidc_publishers_admin_disabled( view = views.ManageOIDCPublisherViews(project, pyramid_request) assert view.manage_project_oidc_publishers() == { - "disabled": {"GitHub": True, "Google": True, "ActiveState": True}, + "disabled": { + "GitHub": True, + "GitLab": True, + "Google": True, + "ActiveState": True, + }, "project": project, "github_publisher_form": view.github_publisher_form, + "gitlab_publisher_form": view.gitlab_publisher_form, "google_publisher_form": view.google_publisher_form, "activestate_publisher_form": view.activestate_publisher_form, } @@ -5886,6 +5900,7 @@ def test_manage_project_oidc_publishers_admin_disabled( assert pyramid_request.flags.disallow_oidc.calls == [ pretend.call(), pretend.call(AdminFlagValue.DISALLOW_GITHUB_OIDC), + pretend.call(AdminFlagValue.DISALLOW_GITLAB_OIDC), pretend.call(AdminFlagValue.DISALLOW_GOOGLE_OIDC), pretend.call(AdminFlagValue.DISALLOW_ACTIVESTATE_OIDC), ] @@ -5924,6 +5939,27 @@ def test_manage_project_oidc_publishers_admin_disabled( normalized_environment=publisher.environment, ), ), + ( + "add_gitlab_oidc_publisher", + pretend.stub( + id="fakeid", + publisher_name="GitLab", + project="fakerepo", + publisher_url=( + lambda x=None: "https://gitlab.com/fakeowner/fakerepo" + ), + namespace="fakeowner", + workflow_filepath="subfolder/fakeworkflow.yml", + environment="some-environment", + ), + lambda publisher: pretend.stub( + validate=pretend.call_recorder(lambda: True), + project=pretend.stub(data=publisher.project), + namespace=pretend.stub(data=publisher.namespace), + workflow_filepath=pretend.stub(data=publisher.workflow_filepath), + normalized_environment=publisher.environment, + ), + ), ( "add_google_oidc_publisher", pretend.stub( @@ -6002,6 +6038,7 @@ def test_add_oidc_publisher_preexisting( publisher_form_obj = make_form(publisher) publisher_form_cls = pretend.call_recorder(lambda *a, **kw: publisher_form_obj) monkeypatch.setattr(views, "GitHubPublisherForm", publisher_form_cls) + monkeypatch.setattr(views, "GitLabPublisherForm", publisher_form_cls) monkeypatch.setattr(views, "GooglePublisherForm", publisher_form_cls) monkeypatch.setattr(views, "ActiveStatePublisherForm", publisher_form_cls) @@ -6070,6 +6107,17 @@ def test_add_oidc_publisher_preexisting( ), pretend.stub(publisher_name="GitHub"), ), + ( + "add_gitlab_oidc_publisher", + pretend.stub( + validate=pretend.call_recorder(lambda: True), + project=pretend.stub(data="fakerepo"), + namespace=pretend.stub(data="fakeowner"), + workflow_filepath=pretend.stub(data="subfolder/fakeworkflow.yml"), + normalized_environment="some-environment", + ), + pretend.stub(publisher_name="GitLab"), + ), ( "add_google_oidc_publisher", pretend.stub( @@ -6132,6 +6180,7 @@ def test_add_oidc_publisher_created( publisher_form_cls = pretend.call_recorder(lambda *a, **kw: publisher_form_obj) monkeypatch.setattr(views, "GitHubPublisherForm", publisher_form_cls) + monkeypatch.setattr(views, "GitLabPublisherForm", publisher_form_cls) monkeypatch.setattr(views, "GooglePublisherForm", publisher_form_cls) monkeypatch.setattr(views, "ActiveStatePublisherForm", publisher_form_cls) monkeypatch.setattr( @@ -6223,6 +6272,24 @@ def test_add_oidc_publisher_created( } ), ), + ( + "add_gitlab_oidc_publisher", + "GitLab", + GitLabPublisher( + project="some-repository", + namespace="some-owner", + workflow_filepath="subfolder/some-workflow-filename.yml", + environment="some-environment", + ), + MultiDict( + { + "namespace": "some-owner", + "project": "some-repository", + "workflow_filepath": "subfolder/some-workflow-filename.yml", + "environment": "some-environment", + } + ), + ), ( "add_google_oidc_publisher", "Google", @@ -6310,9 +6377,15 @@ def test_add_oidc_publisher_already_registered_with_project( ) assert getattr(view, view_name)() == { - "disabled": {"GitHub": False, "Google": False, "ActiveState": False}, + "disabled": { + "GitHub": False, + "GitLab": False, + "Google": False, + "ActiveState": False, + }, "project": project, "github_publisher_form": view.github_publisher_form, + "gitlab_publisher_form": view.gitlab_publisher_form, "google_publisher_form": view.google_publisher_form, "activestate_publisher_form": view.activestate_publisher_form, } @@ -6334,6 +6407,7 @@ def test_add_oidc_publisher_already_registered_with_project( "view_name, publisher_name", [ ("add_github_oidc_publisher", "GitHub"), + ("add_gitlab_oidc_publisher", "GitLab"), ("add_google_oidc_publisher", "Google"), ("add_activestate_oidc_publisher", "ActiveState"), ], @@ -6383,6 +6457,7 @@ def test_add_oidc_publisher_ratelimited( "view_name, publisher_name", [ ("add_github_oidc_publisher", "GitHub"), + ("add_gitlab_oidc_publisher", "GitLab"), ("add_google_oidc_publisher", "Google"), ("add_activestate_oidc_publisher", "ActiveState"), ], @@ -6425,6 +6500,7 @@ def test_add_oidc_publisher_admin_disabled( "view_name, publisher_name", [ ("add_github_oidc_publisher", "GitHub"), + ("add_gitlab_oidc_publisher", "GitLab"), ("add_google_oidc_publisher", "Google"), ("add_activestate_oidc_publisher", "ActiveState"), ], @@ -6450,12 +6526,14 @@ def test_add_oidc_publisher_invalid_form( ) publisher_form_cls = pretend.call_recorder(lambda *a, **kw: publisher_form_obj) monkeypatch.setattr(views, "GitHubPublisherForm", publisher_form_cls) + monkeypatch.setattr(views, "GitLabPublisherForm", publisher_form_cls) monkeypatch.setattr(views, "GooglePublisherForm", publisher_form_cls) monkeypatch.setattr(views, "ActiveStatePublisherForm", publisher_form_cls) view = views.ManageOIDCPublisherViews(project, request) default_response = { "github_publisher_form": publisher_form_obj, + "gitlab_publisher_form": publisher_form_obj, "google_publisher_form": publisher_form_obj, "activestate_publisher_form": publisher_form_obj, } @@ -6490,6 +6568,12 @@ def test_add_oidc_publisher_invalid_form( workflow_filename="some-workflow-filename.yml", environment="some-environment", ), + GitLabPublisher( + project="some-repository", + namespace="some-owner", + workflow_filepath="subfolder/some-workflow-filename.yml", + environment="some-environment", + ), GooglePublisher( email="some-email@example.com", sub="some-sub", @@ -6598,6 +6682,12 @@ def test_delete_oidc_publisher_registered_to_multiple_projects( workflow_filename="some-workflow-filename.yml", environment="some-environment", ), + GitLabPublisher( + project="some-repository", + namespace="some-owner", + workflow_filepath="subfolder/some-workflow-filename.yml", + environment="some-environment", + ), GooglePublisher( email="some-email@example.com", sub="some-sub", diff --git a/tests/unit/oidc/forms/test_gitlab.py b/tests/unit/oidc/forms/test_gitlab.py new file mode 100644 index 000000000000..25133694d8c0 --- /dev/null +++ b/tests/unit/oidc/forms/test_gitlab.py @@ -0,0 +1,133 @@ +# 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 webob.multidict import MultiDict + +from warehouse.oidc.forms import gitlab + + +class TestPendingGitLabPublisherForm: + def test_validate(self, monkeypatch): + project_factory = [] + data = MultiDict( + { + "namespace": "some-owner", + "project": "some-repo", + "workflow_filepath": "subfolder/some-workflow.yml", + "project_name": "some-project", + } + ) + form = gitlab.PendingGitLabPublisherForm( + MultiDict(data), project_factory=project_factory + ) + + assert form._project_factory == project_factory + # We're testing only the basic validation here. + assert form.validate() + + def test_validate_project_name_already_in_use(self): + project_factory = ["some-project"] + form = gitlab.PendingGitLabPublisherForm(project_factory=project_factory) + + field = pretend.stub(data="some-project") + with pytest.raises(wtforms.validators.ValidationError): + form.validate_project_name(field) + + +class TestGitLabPublisherForm: + def test_validate(self): + data = MultiDict( + { + "namespace": "some-owner", + "project": "some-repo", + "workflow_filepath": "subfolder/some-workflow.yml", + } + ) + form = gitlab.GitLabPublisherForm(MultiDict(data)) + + # We're testing only the basic validation here. + assert form.validate(), str(form.errors) + + @pytest.mark.parametrize( + "data", + [ + {"namespace": None, "project": "some", "workflow_filepath": "some"}, + {"namespace": "", "project": "some", "workflow_filepath": "some"}, + { + "namespace": "invalid_characters@", + "project": "some", + "workflow_filepath": "some", + }, + { + "namespace": "invalid_parethen(sis", + "project": "some", + "workflow_filepath": "some", + }, + { + "namespace": "some", + "project": "invalid space", + "workflow_filepath": "some", + }, + { + "namespace": "some", + "project": "invalid+plus", + "workflow_filepath": "some", + }, + {"project": None, "namespace": "some", "workflow_filepath": "some"}, + {"project": "", "namespace": "some", "workflow_filepath": "some"}, + { + "project": "$invalid#characters", + "namespace": "some", + "workflow_filepath": "some", + }, + {"project": "some", "namespace": "some", "workflow_filepath": None}, + {"project": "some", "namespace": "some", "workflow_filepath": ""}, + ], + ) + def test_validate_basic_invalid_fields(self, monkeypatch, data): + form = gitlab.GitLabPublisherForm(MultiDict(data)) + + # We're testing only the basic validation here. + assert not form.validate() + + @pytest.mark.parametrize( + "workflow_filepath", + [ + "missing_suffix", + "/begin_slash.yml", + "end_with_slash.yml/", + "/begin/and/end/slash.yml/", + ], + ) + def test_validate_workflow_filepath(self, workflow_filepath): + form = gitlab.GitLabPublisherForm() + field = pretend.stub(data=workflow_filepath) + + with pytest.raises(wtforms.validators.ValidationError): + form.validate_workflow_filepath(field) + + @pytest.mark.parametrize( + "data, expected", + [ + ("", ""), + (" ", ""), + ("\t\r\n", ""), + (None, ""), + ], + ) + def test_normalized_environment(self, data, expected): + form = gitlab.GitLabPublisherForm(environment=data) + assert form.normalized_environment == expected diff --git a/tests/unit/oidc/models/test_gitlab.py b/tests/unit/oidc/models/test_gitlab.py new file mode 100644 index 000000000000..01c2dadf5538 --- /dev/null +++ b/tests/unit/oidc/models/test_gitlab.py @@ -0,0 +1,520 @@ +# 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 sqlalchemy + +from tests.common.db.oidc import GitLabPublisherFactory, PendingGitLabPublisherFactory +from warehouse.oidc import errors +from warehouse.oidc.models import _core, gitlab + + +@pytest.mark.parametrize("claim", ["", "repo", "repo:"]) +def test_check_sub(claim): + assert gitlab._check_sub(pretend.stub(), claim, pretend.stub()) is False + + +def test_lookup_strategies(): + assert ( + len(gitlab.GitLabPublisher.__lookup_strategies__) + == len(gitlab.PendingGitLabPublisher.__lookup_strategies__) + == 2 + ) + + +class TestGitLabPublisher: + def test_lookup_strategies(self): + assert ( + len(gitlab.GitLabPublisher.__lookup_strategies__) + == len(gitlab.PendingGitLabPublisher.__lookup_strategies__) + == 2 + ) + + def test_gitlab_publisher_all_known_claims(self): + assert gitlab.GitLabPublisher.all_known_claims() == { + # required verifiable claims + "sub", + "project_path", + "ci_config_ref_uri", + # required unverifiable claims + "ref_path", + "sha", + # optional verifiable claims + "environment", + # preverified claims + "iss", + "iat", + "nbf", + "exp", + "aud", + # unchecked claims + "namespace_id", + "namespace_path", + "user_id", + "user_login", + "user_email", + "user_identities", + "pipeline_id", + "pipeline_source", + "job_id", + "ref", + "ref_type", + "ref_protected", + "environment_protected", + "deployment_tier", + "environment_action", + "runner_id", + "runner_environment", + "ci_config_sha", + "project_visibility", + "jti", + } + + def test_gitlab_publisher_computed_properties(self): + publisher = gitlab.GitLabPublisher( + project="fakerepo", + namespace="fakeowner", + workflow_filepath="subfolder/fakeworkflow.yml", + environment="fakeenv", + ) + + for claim_name in publisher.__required_verifiable_claims__.keys(): + assert getattr(publisher, claim_name) is not None + + assert str(publisher) == "subfolder/fakeworkflow.yml" + assert publisher.publisher_url() == "https://gitlab.com/fakeowner/fakerepo" + assert ( + publisher.publisher_url({"sha": "somesha"}) + == "https://gitlab.com/fakeowner/fakerepo/commit/somesha" + ) + assert publisher.stored_claims({"sha": "somesha", "ref_path": "someref"}) == { + "sha": "somesha", + "ref_path": "someref", + } + + def test_gitlab_publisher_unaccounted_claims(self, monkeypatch): + publisher = gitlab.GitLabPublisher( + project="fakerepo", + namespace="fakeowner", + workflow_filepath="subfolder/fakeworkflow.yml", + ) + + scope = pretend.stub() + sentry_sdk = pretend.stub( + capture_message=pretend.call_recorder(lambda s: None), + push_scope=pretend.call_recorder( + lambda: pretend.stub( + __enter__=lambda *a: scope, __exit__=lambda *a: None + ) + ), + ) + monkeypatch.setattr(_core, "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 gitlab.GitLabPublisher.all_known_claims() + } + signed_claims["fake-claim"] = "fake" + signed_claims["another-fake-claim"] = "also-fake" + with pytest.raises(errors.InvalidPublisherError) as e: + publisher.verify_claims(signed_claims=signed_claims) + assert str(e.value) == "Check failed for required claim 'sub'" + assert sentry_sdk.capture_message.calls == [ + pretend.call( + "JWT for GitLabPublisher has unaccounted claims: " + "['another-fake-claim', 'fake-claim']" + ) + ] + assert scope.fingerprint == ["another-fake-claim", "fake-claim"] + + @pytest.mark.parametrize("missing", ["sub", "ref_path"]) + def test_gitlab_publisher_missing_claims(self, monkeypatch, missing): + publisher = gitlab.GitLabPublisher( + project="fakerepo", + namespace="fakeowner", + workflow_filepath="subfolder/fakeworkflow.yml", + ) + + scope = pretend.stub() + sentry_sdk = pretend.stub( + capture_message=pretend.call_recorder(lambda s: None), + push_scope=pretend.call_recorder( + lambda: pretend.stub( + __enter__=lambda *a: scope, __exit__=lambda *a: None + ) + ), + ) + monkeypatch.setattr(_core, "sentry_sdk", sentry_sdk) + + signed_claims = { + claim_name: "fake" + for claim_name in gitlab.GitLabPublisher.all_known_claims() + } + # Pop the missing claim, so that it's missing. + signed_claims.pop(missing) + assert missing not in signed_claims + assert publisher.__required_verifiable_claims__ + with pytest.raises(errors.InvalidPublisherError) as e: + publisher.verify_claims(signed_claims=signed_claims) + assert str(e.value) == f"Missing claim {missing!r}" + assert sentry_sdk.capture_message.calls == [ + pretend.call(f"JWT for GitLabPublisher is missing claim: {missing}") + ] + assert scope.fingerprint == [missing] + + def test_gitlab_publisher_missing_optional_claims(self, monkeypatch): + publisher = gitlab.GitLabPublisher( + project="fakerepo", + namespace="fakeowner", + workflow_filepath="subfolder/fakeworkflow.yml", + environment="some-environment", # The optional claim that should be present + ) + + sentry_sdk = pretend.stub(capture_message=pretend.call_recorder(lambda s: None)) + monkeypatch.setattr(_core, "sentry_sdk", sentry_sdk) + + signed_claims = { + claim_name: getattr(publisher, claim_name) + for claim_name in gitlab.GitLabPublisher.__required_verifiable_claims__ + } + signed_claims["ref_path"] = "ref" + signed_claims["sha"] = "sha" + signed_claims["ci_config_ref_uri"] = publisher.ci_config_ref_uri + "@ref" + assert publisher.__required_verifiable_claims__ + with pytest.raises(errors.InvalidPublisherError) as e: + publisher.verify_claims(signed_claims=signed_claims) + assert str(e.value) == "Check failed for optional claim 'environment'" + assert sentry_sdk.capture_message.calls == [] + + @pytest.mark.parametrize("environment", [None, "some-environment"]) + @pytest.mark.parametrize( + "missing_claims", + [set(), gitlab.GitLabPublisher.__optional_verifiable_claims__.keys()], + ) + def test_gitlab_publisher_verifies(self, monkeypatch, environment, missing_claims): + publisher = gitlab.GitLabPublisher( + project="fakerepo", + namespace="fakeowner", + workflow_filepath="subfolder/fakeworkflow.yml", + environment="environment", + ) + + noop_check = pretend.call_recorder(lambda gt, sc, ac: True) + verifiable_claims = { + claim_name: noop_check + for claim_name in publisher.__required_verifiable_claims__ + } + monkeypatch.setattr( + publisher, "__required_verifiable_claims__", verifiable_claims + ) + optional_verifiable_claims = { + claim_name: noop_check + for claim_name in publisher.__optional_verifiable_claims__ + } + monkeypatch.setattr( + publisher, "__optional_verifiable_claims__", optional_verifiable_claims + ) + + signed_claims = { + claim_name: "fake" + for claim_name in gitlab.GitLabPublisher.all_known_claims() + if claim_name not in missing_claims + } + assert publisher.verify_claims(signed_claims=signed_claims) + assert len(noop_check.calls) == len(verifiable_claims) + len( + optional_verifiable_claims + ) + + @pytest.mark.parametrize( + ("claim", "ref_path", "sha", "valid", "expected"), + [ + # okay: workflow name, followed by a nonempty ref_path + ( + "gitlab.com/foo/bar//workflows/baz.yml@refs/tags/v0.0.1", + "refs/tags/v0.0.1", + "somesha", + True, + None, + ), + ( + "gitlab.com/foo/bar//workflows/baz.yml@refs/pulls/6", + "refs/pulls/6", + "somesha", + True, + None, + ), + ( + "gitlab.com/foo/bar//workflows/baz.yml@refs/heads/main", + "refs/heads/main", + "somesha", + True, + None, + ), + ( + "gitlab.com/foo/bar//workflows/baz.yml@notrailingslash", + "notrailingslash", + "somesha", + True, + None, + ), + # okay: workflow name, followed by a nonempty sha + ( + "gitlab.com/foo/bar//workflows/baz.yml@somesha", + "someref", + "somesha", + True, + None, + ), + # bad: either ref_path or sha empty + ( + "gitlab.com/foo/bar//workflows/baz.yml@somesha", + None, + "somesha", + False, + "The ref_path and sha claims are empty", + ), + ( + "gitlab.com/foo/bar//workflows/baz.yml@somesha", + "", + "somesha", + False, + "The ref_path and sha claims are empty", + ), + ( + "gitlab.com/foo/bar//workflows/baz.yml@missing", + "someref", + None, + False, + "The ref_path and sha claims are empty", + ), + ( + "gitlab.com/foo/bar//workflows/baz.yml@missing", + "someref", + "", + False, + "The ref_path and sha claims are empty", + ), + # bad: both ref_path and sha are missing + ( + "gitlab.com/foo/bar//workflows/baz.yml@missing", + None, + None, + False, + "The ref_path and sha claims are empty", + ), + ( + "gitlab.com/foo/bar//workflows/baz.yml@missing", + "", + "", + False, + "The ref_path and sha claims are empty", + ), + # bad: workflow name with various attempted impersonations on the ref_path + ( + "gitlab.com/foo/bar//workflows/baz.yml@fake.yml@notrailingslash", + "somesha", + "notrailingslash", + False, + "The ci_config_ref_uri claim does not match, expecting one of " + "['gitlab.com/foo/bar//workflows/baz.yml@notrailingslash', " + "'gitlab.com/foo/bar//workflows/baz.yml@somesha'], " + "got 'gitlab.com/foo/bar//workflows/baz.yml@fake.yml@notrailingslash'", + ), + ( + "gitlab.com/foo/bar//workflows/baz.yml@fake.yml@refs/pulls/6", + "somesha", + "refs/pulls/6", + False, + "The ci_config_ref_uri claim does not match, expecting one of " + "['gitlab.com/foo/bar//workflows/baz.yml@refs/pulls/6', " + "'gitlab.com/foo/bar//workflows/baz.yml@somesha'], " + "got 'gitlab.com/foo/bar//workflows/baz.yml@fake.yml@refs/pulls/6'", + ), + # bad: missing tail or workflow name or otherwise partial + ( + "gitlab.com/foo/bar//workflows/baz.yml@", + "somesha", + "notrailingslash", + False, + "The ci_config_ref_uri claim does not match, expecting one of " + "['gitlab.com/foo/bar//workflows/baz.yml@notrailingslash', " + "'gitlab.com/foo/bar//workflows/baz.yml@somesha'], " + "got 'gitlab.com/foo/bar//workflows/baz.yml@'", + ), + ( + "gitlab.com/foo/bar//workflows/@", + "somesha", + "notrailingslash", + False, + "The ci_config_ref_uri claim does not match, expecting one of " + "['gitlab.com/foo/bar//workflows/baz.yml@notrailingslash', " + "'gitlab.com/foo/bar//workflows/baz.yml@somesha'], " + "got 'gitlab.com/foo/bar//workflows/@'", + ), + ( + "gitlab.com/foo/bar//workflows/", + "somesha", + "notrailingslash", + False, + "The ci_config_ref_uri claim does not match, expecting one of " + "['gitlab.com/foo/bar//workflows/baz.yml@notrailingslash', " + "'gitlab.com/foo/bar//workflows/baz.yml@somesha'], " + "got 'gitlab.com/foo/bar//workflows/'", + ), + ( + "baz.yml", + "somesha", + "notrailingslash", + False, + "The ci_config_ref_uri claim does not match, expecting one of " + "['gitlab.com/foo/bar//workflows/baz.yml@notrailingslash', " + "'gitlab.com/foo/bar//workflows/baz.yml@somesha'], " + "got 'baz.yml'", + ), + ( + "gitlab.com/foo/bar//workflows/baz.yml@malicious.yml@", + "somesha", + "notrailingslash", + False, + "The ci_config_ref_uri claim does not match, expecting one of " + "['gitlab.com/foo/bar//workflows/baz.yml@notrailingslash', " + "'gitlab.com/foo/bar//workflows/baz.yml@somesha'], " + "got 'gitlab.com/foo/bar//workflows/baz.yml@malicious.yml@'", + ), + ( + "gitlab.com/foo/bar//workflows/baz.yml@@", + "somesha", + "notrailingslash", + False, + "The ci_config_ref_uri claim does not match, expecting one of " + "['gitlab.com/foo/bar//workflows/baz.yml@notrailingslash', " + "'gitlab.com/foo/bar//workflows/baz.yml@somesha'], " + "got 'gitlab.com/foo/bar//workflows/baz.yml@@'", + ), + ("", None, None, False, "The ci_config_ref_uri claim is empty"), + ], + ) + def test_gitlab_publisher_ci_config_ref_uri( + self, claim, ref_path, sha, valid, expected + ): + publisher = gitlab.GitLabPublisher( + project="bar", + namespace="foo", + workflow_filepath="workflows/baz.yml", + ) + + check = gitlab.GitLabPublisher.__required_verifiable_claims__[ + "ci_config_ref_uri" + ] + claims = {"ref_path": ref_path, "sha": sha} + if valid: + assert check(publisher.ci_config_ref_uri, claim, claims) is True + else: + with pytest.raises(errors.InvalidPublisherError) as e: + check(publisher.ci_config_ref_uri, claim, claims) is True + assert str(e.value) == expected + + @pytest.mark.parametrize( + ("truth", "claim", "valid"), + [ + ("repo:foo/bar", "repo:foo/bar:someotherstuff", True), + ("repo:foo/bar", "repo:foo/bar:", True), + ("repo:fOo/BaR", "repo:foo/bar", True), + ("repo:foo/bar", "repo:fOo/BaR:", True), + ("repo:foo/bar:someotherstuff", "repo:foo/bar", False), + ("repo:foo/bar-baz", "repo:foo/bar", False), + ("repo:foo/bar", "repo:foo/bar-baz", False), + ], + ) + def test_gitlab_publisher_sub_claim(self, truth, claim, valid): + check = gitlab.GitLabPublisher.__required_verifiable_claims__["sub"] + assert check(truth, claim, pretend.stub()) is valid + + @pytest.mark.parametrize( + ("truth", "claim", "valid"), + [ + ("", None, True), + ("", "", True), + ("", "some-environment", True), + ("some-environment", "some-environment", True), + ("some-environment", "sOmE-eNvIrOnMeNt", False), + ("some-environment", None, False), + ("some-environment", "some-other-environment", False), + ], + ) + def test_gitlab_publisher_environment_claim(self, truth, claim, valid): + check = gitlab.GitLabPublisher.__optional_verifiable_claims__["environment"] + assert check(truth, claim, pretend.stub()) is valid + + def test_gitlab_publisher_duplicates_cant_be_created(self, db_request): + publisher1 = gitlab.GitLabPublisher( + project="repository_name", + namespace="repository_owner", + workflow_filepath="subfolder/worflow_filename.yml", + environment="", + ) + + db_request.db.add(publisher1) + db_request.db.commit() + + with pytest.raises(sqlalchemy.exc.IntegrityError): + publisher2 = gitlab.GitLabPublisher( + project="repository_name", + namespace="repository_owner", + workflow_filepath="subfolder/worflow_filename.yml", + environment="", + ) + db_request.db.add(publisher2) + db_request.db.commit() + + +class TestPendingGitLabPublisher: + def test_reify_does_not_exist_yet(self, db_request): + pending_publisher = PendingGitLabPublisherFactory.create() + assert ( + db_request.db.query(gitlab.GitLabPublisher) + .filter_by( + project=pending_publisher.project, + namespace=pending_publisher.namespace, + workflow_filepath=pending_publisher.workflow_filepath, + environment=pending_publisher.environment, + ) + .one_or_none() + is None + ) + publisher = pending_publisher.reify(db_request.db) + + # If an OIDC publisher for this pending publisher does not already exist, + # a new one is created and the pending publisher is marked for deletion. + assert isinstance(publisher, gitlab.GitLabPublisher) + assert pending_publisher in db_request.db.deleted + assert publisher.project == pending_publisher.project + assert publisher.namespace == pending_publisher.namespace + assert publisher.workflow_filepath == pending_publisher.workflow_filepath + assert publisher.environment == pending_publisher.environment + + def test_reify_already_exists(self, db_request): + existing_publisher = GitLabPublisherFactory.create() + pending_publisher = PendingGitLabPublisherFactory.create( + project=existing_publisher.project, + namespace=existing_publisher.namespace, + workflow_filepath=existing_publisher.workflow_filepath, + environment=existing_publisher.environment, + ) + publisher = pending_publisher.reify(db_request.db) + + # If an OIDC publisher for this pending publisher already exists, + # it is returned and the pending publisher is marked for deletion. + assert existing_publisher == publisher + assert pending_publisher in db_request.db.deleted diff --git a/tests/unit/oidc/test_utils.py b/tests/unit/oidc/test_utils.py index 7dbc628ffc75..9f6b432d42c8 100644 --- a/tests/unit/oidc/test_utils.py +++ b/tests/unit/oidc/test_utils.py @@ -20,6 +20,7 @@ from tests.common.db.oidc import ( ActiveStatePublisherFactory, GitHubPublisherFactory, + GitLabPublisherFactory, GooglePublisherFactory, ) from warehouse.oidc import errors, utils @@ -78,6 +79,47 @@ def test_find_publisher_by_issuer_github(db_request, environment, expected_id): ) +@pytest.mark.parametrize( + "environment, expected_id", + [ + (None, uuid.UUID("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa")), + ("some_other_environment", uuid.UUID("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa")), + ("some_environment", uuid.UUID("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb")), + ], +) +def test_find_publisher_by_issuer_gitlab(db_request, environment, expected_id): + GitLabPublisherFactory( + id="aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", + namespace="foo", + project="bar", + workflow_filepath="workflows/ci.yml", + environment="", # No environment + ) + GitLabPublisherFactory( + id="bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb", + namespace="foo", + project="bar", + workflow_filepath="workflows/ci.yml", + environment="some_environment", # Environment set + ) + + signed_claims = { + "project_path": "foo/bar", + "ci_config_ref_uri": "gitlab.com/foo/bar//workflows/ci.yml@refs/heads/main", + } + if environment: + signed_claims["environment"] = environment + + assert ( + utils.find_publisher_by_issuer( + db_request.db, + utils.GITLAB_OIDC_ISSUER_URL, + signed_claims, + ).id + == expected_id + ) + + @pytest.mark.parametrize( "sub, expected_id", [ diff --git a/tests/unit/oidc/test_views.py b/tests/unit/oidc/test_views.py index ac30d2527f15..02bb83341dae 100644 --- a/tests/unit/oidc/test_views.py +++ b/tests/unit/oidc/test_views.py @@ -262,6 +262,14 @@ def body(self): ), "google", ), + ( + ( + "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJodHRwczovL2dpd" + "GxhYi5jb20iLCJpYXQiOjE3MDYwMjYxNjR9.EcmGXp-aFWLrwbNm5QIjDAQ_mR" + "sHtF7obbcnu4w_ZSU" + ), + "gitlab", + ), ], ) def test_mint_token_from_oidc_creates_expected_service( diff --git a/warehouse/accounts/views.py b/warehouse/accounts/views.py index 4c8031c32d3a..8a3ad8086b10 100644 --- a/warehouse/accounts/views.py +++ b/warehouse/accounts/views.py @@ -82,12 +82,14 @@ DeletePublisherForm, PendingActiveStatePublisherForm, PendingGitHubPublisherForm, + PendingGitLabPublisherForm, PendingGooglePublisherForm, ) from warehouse.oidc.interfaces import TooManyOIDCRegistrations from warehouse.oidc.models import ( PendingActiveStatePublisher, PendingGitHubPublisher, + PendingGitLabPublisher, PendingGooglePublisher, PendingOIDCPublisher, ) @@ -1481,6 +1483,10 @@ def __init__(self, request): api_token=self.request.registry.settings.get("github.token"), project_factory=self.project_factory, ) + self.pending_gitlab_publisher_form = PendingGitLabPublisherForm( + self.request.POST, + project_factory=self.project_factory, + ) self.pending_google_publisher_form = PendingGooglePublisherForm( self.request.POST, project_factory=self.project_factory, @@ -1524,12 +1530,16 @@ def _check_ratelimits(self): def default_response(self): return { "pending_github_publisher_form": self.pending_github_publisher_form, + "pending_gitlab_publisher_form": self.pending_gitlab_publisher_form, "pending_google_publisher_form": self.pending_google_publisher_form, "pending_activestate_publisher_form": self.pending_activestate_publisher_form, # noqa: E501 "disabled": { "GitHub": self.request.flags.disallow_oidc( AdminFlagValue.DISALLOW_GITHUB_OIDC ), + "GitLab": self.request.flags.disallow_oidc( + AdminFlagValue.DISALLOW_GITLAB_OIDC + ), "Google": self.request.flags.disallow_oidc( AdminFlagValue.DISALLOW_GOOGLE_OIDC ), @@ -1752,6 +1762,33 @@ def add_pending_activestate_oidc_publisher(self): ), ) + @view_config( + request_method="POST", + request_param=PendingGitLabPublisherForm.__params__, + ) + def add_pending_gitlab_oidc_publisher(self): + form = self.default_response["pending_gitlab_publisher_form"] + return self._add_pending_oidc_publisher( + publisher_name="GitLab", + publisher_class=PendingGitLabPublisher, + admin_flag=AdminFlagValue.DISALLOW_GITLAB_OIDC, + form=form, + make_pending_publisher=lambda request, form: PendingGitLabPublisher( + project_name=form.project_name.data, + added_by=self.request.user, + namespace=form.namespace.data, + project=form.project.data, + workflow_filepath=form.workflow_filepath.data, + environment=form.normalized_environment, + ), + make_existence_filters=lambda form: dict( + namespace=form.namespace.data, + project=form.project.data, + workflow_filepath=form.workflow_filepath.data, + environment=form.normalized_environment, + ), + ) + @view_config( request_method="POST", request_param=DeletePublisherForm.__params__, diff --git a/warehouse/admin/flags.py b/warehouse/admin/flags.py index d2b23bf7a5ef..497a659982d6 100644 --- a/warehouse/admin/flags.py +++ b/warehouse/admin/flags.py @@ -26,6 +26,7 @@ class AdminFlagValue(enum.Enum): DISALLOW_NEW_USER_REGISTRATION = "disallow-new-user-registration" DISALLOW_OIDC = "disallow-oidc" DISALLOW_GITHUB_OIDC = "disallow-github-oidc" + DISALLOW_GITLAB_OIDC = "disallow-gitlab-oidc" DISALLOW_GOOGLE_OIDC = "disallow-google-oidc" DISALLOW_ACTIVESTATE_OIDC = "disallow-activestate-oidc" READ_ONLY = "read-only" diff --git a/warehouse/locale/messages.pot b/warehouse/locale/messages.pot index 801d6e49a5e4..8ea87b5c30e1 100644 --- a/warehouse/locale/messages.pot +++ b/warehouse/locale/messages.pot @@ -114,223 +114,225 @@ msgstr "" msgid "The username isn't valid. Try again." msgstr "" -#: warehouse/accounts/views.py:118 +#: warehouse/accounts/views.py:120 msgid "" "There have been too many unsuccessful login attempts. You have been " "locked out for {}. Please try again later." msgstr "" -#: warehouse/accounts/views.py:135 +#: warehouse/accounts/views.py:137 msgid "" "Too many emails have been added to this account without verifying them. " "Check your inbox and follow the verification links. (IP: ${ip})" msgstr "" -#: warehouse/accounts/views.py:147 +#: warehouse/accounts/views.py:149 msgid "" "Too many password resets have been requested for this account without " "completing them. Check your inbox and follow the verification links. (IP:" " ${ip})" msgstr "" -#: warehouse/accounts/views.py:329 warehouse/accounts/views.py:398 -#: warehouse/accounts/views.py:400 warehouse/accounts/views.py:429 -#: warehouse/accounts/views.py:431 warehouse/accounts/views.py:537 +#: warehouse/accounts/views.py:331 warehouse/accounts/views.py:400 +#: warehouse/accounts/views.py:402 warehouse/accounts/views.py:431 +#: warehouse/accounts/views.py:433 warehouse/accounts/views.py:539 msgid "Invalid or expired two factor login." msgstr "" -#: warehouse/accounts/views.py:392 +#: warehouse/accounts/views.py:394 msgid "Already authenticated" msgstr "" -#: warehouse/accounts/views.py:472 +#: warehouse/accounts/views.py:474 msgid "Successful WebAuthn assertion" msgstr "" -#: warehouse/accounts/views.py:568 warehouse/manage/views/__init__.py:833 +#: warehouse/accounts/views.py:570 warehouse/manage/views/__init__.py:835 msgid "Recovery code accepted. The supplied code cannot be used again." msgstr "" -#: warehouse/accounts/views.py:660 +#: warehouse/accounts/views.py:662 msgid "" "New user registration temporarily disabled. See https://pypi.org/help" "#admin-intervention for details." msgstr "" -#: warehouse/accounts/views.py:797 +#: warehouse/accounts/views.py:799 msgid "Expired token: request a new password reset link" msgstr "" -#: warehouse/accounts/views.py:799 +#: warehouse/accounts/views.py:801 msgid "Invalid token: request a new password reset link" msgstr "" -#: warehouse/accounts/views.py:801 warehouse/accounts/views.py:914 -#: warehouse/accounts/views.py:1018 warehouse/accounts/views.py:1187 +#: warehouse/accounts/views.py:803 warehouse/accounts/views.py:916 +#: warehouse/accounts/views.py:1020 warehouse/accounts/views.py:1189 msgid "Invalid token: no token supplied" msgstr "" -#: warehouse/accounts/views.py:805 +#: warehouse/accounts/views.py:807 msgid "Invalid token: not a password reset token" msgstr "" -#: warehouse/accounts/views.py:810 +#: warehouse/accounts/views.py:812 msgid "Invalid token: user not found" msgstr "" -#: warehouse/accounts/views.py:832 +#: warehouse/accounts/views.py:834 msgid "Invalid token: user has logged in since this token was requested" msgstr "" -#: warehouse/accounts/views.py:850 +#: warehouse/accounts/views.py:852 msgid "" "Invalid token: password has already been changed since this token was " "requested" msgstr "" -#: warehouse/accounts/views.py:882 +#: warehouse/accounts/views.py:884 msgid "You have reset your password" msgstr "" -#: warehouse/accounts/views.py:910 +#: warehouse/accounts/views.py:912 msgid "Expired token: request a new email verification link" msgstr "" -#: warehouse/accounts/views.py:912 +#: warehouse/accounts/views.py:914 msgid "Invalid token: request a new email verification link" msgstr "" -#: warehouse/accounts/views.py:918 +#: warehouse/accounts/views.py:920 msgid "Invalid token: not an email verification token" msgstr "" -#: warehouse/accounts/views.py:927 +#: warehouse/accounts/views.py:929 msgid "Email not found" msgstr "" -#: warehouse/accounts/views.py:930 +#: warehouse/accounts/views.py:932 msgid "Email already verified" msgstr "" -#: warehouse/accounts/views.py:947 +#: warehouse/accounts/views.py:949 msgid "You can now set this email as your primary address" msgstr "" -#: warehouse/accounts/views.py:951 +#: warehouse/accounts/views.py:953 msgid "This is your primary address" msgstr "" -#: warehouse/accounts/views.py:956 +#: warehouse/accounts/views.py:958 msgid "Email address ${email_address} verified. ${confirm_message}." msgstr "" -#: warehouse/accounts/views.py:1014 +#: warehouse/accounts/views.py:1016 msgid "Expired token: request a new organization invitation" msgstr "" -#: warehouse/accounts/views.py:1016 +#: warehouse/accounts/views.py:1018 msgid "Invalid token: request a new organization invitation" msgstr "" -#: warehouse/accounts/views.py:1022 +#: warehouse/accounts/views.py:1024 msgid "Invalid token: not an organization invitation token" msgstr "" -#: warehouse/accounts/views.py:1026 +#: warehouse/accounts/views.py:1028 msgid "Organization invitation is not valid." msgstr "" -#: warehouse/accounts/views.py:1035 +#: warehouse/accounts/views.py:1037 msgid "Organization invitation no longer exists." msgstr "" -#: warehouse/accounts/views.py:1086 +#: warehouse/accounts/views.py:1088 msgid "Invitation for '${organization_name}' is declined." msgstr "" -#: warehouse/accounts/views.py:1149 +#: warehouse/accounts/views.py:1151 msgid "You are now ${role} of the '${organization_name}' organization." msgstr "" -#: warehouse/accounts/views.py:1183 +#: warehouse/accounts/views.py:1185 msgid "Expired token: request a new project role invitation" msgstr "" -#: warehouse/accounts/views.py:1185 +#: warehouse/accounts/views.py:1187 msgid "Invalid token: request a new project role invitation" msgstr "" -#: warehouse/accounts/views.py:1191 +#: warehouse/accounts/views.py:1193 msgid "Invalid token: not a collaboration invitation token" msgstr "" -#: warehouse/accounts/views.py:1195 +#: warehouse/accounts/views.py:1197 msgid "Role invitation is not valid." msgstr "" -#: warehouse/accounts/views.py:1210 +#: warehouse/accounts/views.py:1212 msgid "Role invitation no longer exists." msgstr "" -#: warehouse/accounts/views.py:1241 +#: warehouse/accounts/views.py:1243 msgid "Invitation for '${project_name}' is declined." msgstr "" -#: warehouse/accounts/views.py:1307 +#: warehouse/accounts/views.py:1309 msgid "You are now ${role} of the '${project_name}' project." msgstr "" -#: warehouse/accounts/views.py:1546 warehouse/accounts/views.py:1762 -#: warehouse/manage/views/__init__.py:1205 +#: warehouse/accounts/views.py:1556 warehouse/accounts/views.py:1799 +#: warehouse/manage/views/__init__.py:1212 msgid "" "Trusted publishing is temporarily disabled. See https://pypi.org/help" "#admin-intervention for details." msgstr "" -#: warehouse/accounts/views.py:1567 +#: warehouse/accounts/views.py:1577 msgid "disabled. See https://pypi.org/help#admin-intervention for details." msgstr "" -#: warehouse/accounts/views.py:1583 +#: warehouse/accounts/views.py:1593 msgid "" "You must have a verified email in order to register a pending trusted " "publisher. See https://pypi.org/help#openid-connect for details." msgstr "" -#: warehouse/accounts/views.py:1596 +#: warehouse/accounts/views.py:1606 msgid "You can't register more than 3 pending trusted publishers at once." msgstr "" -#: warehouse/accounts/views.py:1612 warehouse/manage/views/__init__.py:1240 -#: warehouse/manage/views/__init__.py:1353 -#: warehouse/manage/views/__init__.py:1463 +#: warehouse/accounts/views.py:1622 warehouse/manage/views/__init__.py:1247 +#: warehouse/manage/views/__init__.py:1360 +#: warehouse/manage/views/__init__.py:1472 +#: warehouse/manage/views/__init__.py:1582 msgid "" "There have been too many attempted trusted publisher registrations. Try " "again later." msgstr "" -#: warehouse/accounts/views.py:1623 warehouse/manage/views/__init__.py:1254 -#: warehouse/manage/views/__init__.py:1367 -#: warehouse/manage/views/__init__.py:1477 +#: warehouse/accounts/views.py:1633 warehouse/manage/views/__init__.py:1261 +#: warehouse/manage/views/__init__.py:1374 +#: warehouse/manage/views/__init__.py:1486 +#: warehouse/manage/views/__init__.py:1596 msgid "The trusted publisher could not be registered" msgstr "" -#: warehouse/accounts/views.py:1637 +#: warehouse/accounts/views.py:1647 msgid "" "This trusted publisher has already been registered. Please contact PyPI's" " admins if this wasn't intentional." msgstr "" -#: warehouse/accounts/views.py:1664 +#: warehouse/accounts/views.py:1674 msgid "Registered a new pending publisher to create " msgstr "" -#: warehouse/accounts/views.py:1776 warehouse/accounts/views.py:1789 -#: warehouse/accounts/views.py:1796 +#: warehouse/accounts/views.py:1813 warehouse/accounts/views.py:1826 +#: warehouse/accounts/views.py:1833 msgid "Invalid publisher ID" msgstr "" -#: warehouse/accounts/views.py:1802 +#: warehouse/accounts/views.py:1839 msgid "Removed trusted publisher for project " msgstr "" @@ -367,6 +369,7 @@ msgid "Select project" msgstr "" #: warehouse/manage/forms.py:495 warehouse/oidc/forms/_core.py:23 +#: warehouse/oidc/forms/gitlab.py:44 msgid "Specify project name" msgstr "" @@ -420,124 +423,130 @@ msgstr "" msgid "This team name has already been used. Choose a different team name." msgstr "" -#: warehouse/manage/views/__init__.py:201 +#: warehouse/manage/views/__init__.py:203 msgid "Account details updated" msgstr "" -#: warehouse/manage/views/__init__.py:230 +#: warehouse/manage/views/__init__.py:232 msgid "Email ${email_address} added - check your email for a verification link" msgstr "" -#: warehouse/manage/views/__init__.py:781 +#: warehouse/manage/views/__init__.py:783 msgid "Recovery codes already generated" msgstr "" -#: warehouse/manage/views/__init__.py:782 +#: warehouse/manage/views/__init__.py:784 msgid "Generating new recovery codes will invalidate your existing codes." msgstr "" -#: warehouse/manage/views/__init__.py:891 +#: warehouse/manage/views/__init__.py:893 msgid "Verify your email to create an API token." msgstr "" -#: warehouse/manage/views/__init__.py:991 +#: warehouse/manage/views/__init__.py:993 msgid "API Token does not exist." msgstr "" -#: warehouse/manage/views/__init__.py:1023 +#: warehouse/manage/views/__init__.py:1025 msgid "Invalid credentials. Try again" msgstr "" -#: warehouse/manage/views/__init__.py:1221 +#: warehouse/manage/views/__init__.py:1228 msgid "" "GitHub-based trusted publishing is temporarily disabled. See " "https://pypi.org/help#admin-intervention for details." msgstr "" -#: warehouse/manage/views/__init__.py:1334 +#: warehouse/manage/views/__init__.py:1341 +msgid "" +"GitLab-based trusted publishing is temporarily disabled. See " +"https://pypi.org/help#admin-intervention for details." +msgstr "" + +#: warehouse/manage/views/__init__.py:1453 msgid "" "Google-based trusted publishing is temporarily disabled. See " "https://pypi.org/help#admin-intervention for details." msgstr "" -#: warehouse/manage/views/__init__.py:1443 +#: warehouse/manage/views/__init__.py:1562 msgid "" "ActiveState-based trusted publishing is temporarily disabled. See " "https://pypi.org/help#admin-intervention for details." msgstr "" -#: warehouse/manage/views/__init__.py:1678 -#: warehouse/manage/views/__init__.py:1979 -#: warehouse/manage/views/__init__.py:2087 +#: warehouse/manage/views/__init__.py:1797 +#: warehouse/manage/views/__init__.py:2098 +#: warehouse/manage/views/__init__.py:2206 msgid "" "Project deletion temporarily disabled. See https://pypi.org/help#admin-" "intervention for details." msgstr "" -#: warehouse/manage/views/__init__.py:1810 -#: warehouse/manage/views/__init__.py:1895 -#: warehouse/manage/views/__init__.py:1996 -#: warehouse/manage/views/__init__.py:2096 +#: warehouse/manage/views/__init__.py:1929 +#: warehouse/manage/views/__init__.py:2014 +#: warehouse/manage/views/__init__.py:2115 +#: warehouse/manage/views/__init__.py:2215 msgid "Confirm the request" msgstr "" -#: warehouse/manage/views/__init__.py:1822 +#: warehouse/manage/views/__init__.py:1941 msgid "Could not yank release - " msgstr "" -#: warehouse/manage/views/__init__.py:1907 +#: warehouse/manage/views/__init__.py:2026 msgid "Could not un-yank release - " msgstr "" -#: warehouse/manage/views/__init__.py:2008 +#: warehouse/manage/views/__init__.py:2127 msgid "Could not delete release - " msgstr "" -#: warehouse/manage/views/__init__.py:2108 +#: warehouse/manage/views/__init__.py:2227 msgid "Could not find file" msgstr "" -#: warehouse/manage/views/__init__.py:2112 +#: warehouse/manage/views/__init__.py:2231 msgid "Could not delete file - " msgstr "" -#: warehouse/manage/views/__init__.py:2262 +#: warehouse/manage/views/__init__.py:2381 msgid "Team '${team_name}' already has ${role_name} role for project" msgstr "" -#: warehouse/manage/views/__init__.py:2369 +#: warehouse/manage/views/__init__.py:2488 msgid "User '${username}' already has ${role_name} role for project" msgstr "" -#: warehouse/manage/views/__init__.py:2436 +#: warehouse/manage/views/__init__.py:2555 msgid "${username} is now ${role} of the '${project_name}' project." msgstr "" -#: warehouse/manage/views/__init__.py:2468 +#: warehouse/manage/views/__init__.py:2587 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/__init__.py:2481 +#: warehouse/manage/views/__init__.py:2600 #: warehouse/manage/views/organizations.py:881 msgid "User '${username}' already has an active invite. Please try again later." msgstr "" -#: warehouse/manage/views/__init__.py:2546 +#: warehouse/manage/views/__init__.py:2665 #: warehouse/manage/views/organizations.py:946 msgid "Invitation sent to '${username}'" msgstr "" -#: warehouse/manage/views/__init__.py:2579 +#: warehouse/manage/views/__init__.py:2698 msgid "Could not find role invitation." msgstr "" -#: warehouse/manage/views/__init__.py:2590 +#: warehouse/manage/views/__init__.py:2709 msgid "Invitation already expired." msgstr "" -#: warehouse/manage/views/__init__.py:2622 +#: warehouse/manage/views/__init__.py:2741 #: warehouse/manage/views/organizations.py:1133 msgid "Invitation revoked from '${username}'." msgstr "" @@ -565,7 +574,7 @@ msgstr "" msgid "Expired invitation for '${username}' deleted." msgstr "" -#: warehouse/oidc/forms/_core.py:25 +#: warehouse/oidc/forms/_core.py:25 warehouse/oidc/forms/gitlab.py:46 msgid "Invalid project name" msgstr "" @@ -668,6 +677,30 @@ msgstr "" msgid "Workflow filename must be a filename only, without directories" msgstr "" +#: warehouse/oidc/forms/gitlab.py:33 +msgid "Specify GitLab namespace (username or group/subgroup)" +msgstr "" + +#: warehouse/oidc/forms/gitlab.py:37 +msgid "Invalid GitLab username or group/subgroup name." +msgstr "" + +#: warehouse/oidc/forms/gitlab.py:53 +msgid "Specify workflow filepath" +msgstr "" + +#: warehouse/oidc/forms/gitlab.py:61 +msgid "Invalid environment name" +msgstr "" + +#: warehouse/oidc/forms/gitlab.py:76 +msgid "Workflow file path must end with .yml or .yaml" +msgstr "" + +#: warehouse/oidc/forms/gitlab.py:80 +msgid "Workflow file path cannot start or end with /" +msgstr "" + #: warehouse/subscriptions/models.py:35 #: warehouse/templates/manage/project/history.html:230 msgid "Active" @@ -1311,10 +1344,15 @@ msgstr "" #: warehouse/templates/manage/account/publishing.html:159 #: warehouse/templates/manage/account/publishing.html:174 #: warehouse/templates/manage/account/publishing.html:189 -#: warehouse/templates/manage/account/publishing.html:224 -#: warehouse/templates/manage/account/publishing.html:246 -#: warehouse/templates/manage/account/publishing.html:268 -#: warehouse/templates/manage/account/publishing.html:290 +#: warehouse/templates/manage/account/publishing.html:204 +#: warehouse/templates/manage/account/publishing.html:219 +#: warehouse/templates/manage/account/publishing.html:261 +#: warehouse/templates/manage/account/publishing.html:276 +#: warehouse/templates/manage/account/publishing.html:291 +#: warehouse/templates/manage/account/publishing.html:326 +#: warehouse/templates/manage/account/publishing.html:348 +#: warehouse/templates/manage/account/publishing.html:370 +#: warehouse/templates/manage/account/publishing.html:392 #: warehouse/templates/manage/account/recovery_codes-burn.html:70 #: warehouse/templates/manage/account/token.html:133 #: warehouse/templates/manage/account/token.html:150 @@ -1340,9 +1378,13 @@ msgstr "" #: warehouse/templates/manage/project/publishing.html:95 #: warehouse/templates/manage/project/publishing.html:142 #: warehouse/templates/manage/project/publishing.html:157 -#: warehouse/templates/manage/project/publishing.html:192 -#: warehouse/templates/manage/project/publishing.html:214 -#: warehouse/templates/manage/project/publishing.html:236 +#: warehouse/templates/manage/project/publishing.html:172 +#: warehouse/templates/manage/project/publishing.html:187 +#: warehouse/templates/manage/project/publishing.html:229 +#: warehouse/templates/manage/project/publishing.html:244 +#: warehouse/templates/manage/project/publishing.html:279 +#: warehouse/templates/manage/project/publishing.html:301 +#: warehouse/templates/manage/project/publishing.html:323 #: warehouse/templates/manage/project/roles.html:273 #: warehouse/templates/manage/project/roles.html:289 #: warehouse/templates/manage/project/roles.html:305 @@ -2506,15 +2548,15 @@ msgstr "" #: warehouse/templates/email/trusted-publisher-added/body.html:39 #: warehouse/templates/email/trusted-publisher-removed/body.html:37 #: warehouse/templates/includes/accounts/profile-public-email.html:17 -#: warehouse/templates/manage/account/publishing.html:172 -#: warehouse/templates/manage/project/publishing.html:140 +#: warehouse/templates/manage/account/publishing.html:274 +#: warehouse/templates/manage/project/publishing.html:227 msgid "Email" msgstr "" #: warehouse/templates/email/trusted-publisher-added/body.html:41 #: warehouse/templates/email/trusted-publisher-removed/body.html:39 -#: warehouse/templates/manage/account/publishing.html:187 -#: warehouse/templates/manage/project/publishing.html:155 +#: warehouse/templates/manage/account/publishing.html:289 +#: warehouse/templates/manage/project/publishing.html:242 msgid "Subject" msgstr "" @@ -2525,16 +2567,16 @@ msgstr "" #: warehouse/templates/email/trusted-publisher-added/body.html:45 #: warehouse/templates/email/trusted-publisher-removed/body.html:43 -#: warehouse/templates/manage/account/publishing.html:244 -#: warehouse/templates/manage/project/publishing.html:190 +#: warehouse/templates/manage/account/publishing.html:346 +#: warehouse/templates/manage/project/publishing.html:277 #: warehouse/templates/organizations/profile.html:30 msgid "Organization" msgstr "" #: warehouse/templates/email/trusted-publisher-added/body.html:46 #: warehouse/templates/email/trusted-publisher-removed/body.html:44 -#: warehouse/templates/manage/account/publishing.html:266 -#: warehouse/templates/manage/project/publishing.html:212 +#: warehouse/templates/manage/account/publishing.html:368 +#: warehouse/templates/manage/project/publishing.html:299 msgid "ActiveState Project name" msgstr "" @@ -3776,7 +3818,7 @@ msgstr "" #: warehouse/templates/manage/manage_base.html:80 #: warehouse/templates/manage/manage_base.html:97 #: warehouse/templates/manage/manage_base.html:100 -#: warehouse/templates/manage/manage_base.html:566 +#: warehouse/templates/manage/manage_base.html:575 #: warehouse/templates/manage/organization/roles.html:202 #: warehouse/templates/manage/organization/roles.html:204 #: warehouse/templates/manage/organization/roles.html:209 @@ -3961,11 +4003,12 @@ msgid "" msgstr "" #: warehouse/templates/manage/manage_base.html:546 -#: warehouse/templates/manage/manage_base.html:554 +#: warehouse/templates/manage/manage_base.html:555 +#: warehouse/templates/manage/manage_base.html:563 msgid "Any" msgstr "" -#: warehouse/templates/manage/manage_base.html:573 +#: warehouse/templates/manage/manage_base.html:582 #: warehouse/templates/manage/organization/history.html:166 #: warehouse/templates/manage/project/history.html:43 #: warehouse/templates/manage/project/history.html:97 @@ -3976,7 +4019,7 @@ msgstr "" msgid "Added by:" msgstr "" -#: warehouse/templates/manage/manage_base.html:575 +#: warehouse/templates/manage/manage_base.html:584 #: warehouse/templates/manage/organization/history.html:171 #: warehouse/templates/manage/project/history.html:62 #: warehouse/templates/manage/project/history.html:128 @@ -3987,24 +4030,24 @@ msgstr "" msgid "Removed by:" msgstr "" -#: warehouse/templates/manage/manage_base.html:577 +#: warehouse/templates/manage/manage_base.html:586 msgid "Submitted by:" msgstr "" -#: warehouse/templates/manage/manage_base.html:580 +#: warehouse/templates/manage/manage_base.html:589 #: warehouse/templates/manage/project/history.html:247 msgid "Workflow:" msgstr "" -#: warehouse/templates/manage/manage_base.html:582 +#: warehouse/templates/manage/manage_base.html:591 msgid "Specifier:" msgstr "" -#: warehouse/templates/manage/manage_base.html:585 +#: warehouse/templates/manage/manage_base.html:594 msgid "Publisher:" msgstr "" -#: warehouse/templates/manage/manage_base.html:587 +#: warehouse/templates/manage/manage_base.html:596 #: warehouse/templates/manage/project/history.html:52 #: warehouse/templates/manage/project/history.html:106 msgid "URL:" @@ -4280,19 +4323,22 @@ msgstr "" #: warehouse/templates/manage/account/publishing.html:38 #: warehouse/templates/manage/account/publishing.html:157 -#: warehouse/templates/manage/account/publishing.html:222 +#: warehouse/templates/manage/account/publishing.html:259 +#: warehouse/templates/manage/account/publishing.html:324 msgid "PyPI Project Name" msgstr "" #: warehouse/templates/manage/account/publishing.html:43 #: warehouse/templates/manage/account/publishing.html:162 -#: warehouse/templates/manage/account/publishing.html:228 +#: warehouse/templates/manage/account/publishing.html:264 +#: warehouse/templates/manage/account/publishing.html:330 msgid "project name" msgstr "" #: warehouse/templates/manage/account/publishing.html:45 #: warehouse/templates/manage/account/publishing.html:164 -#: warehouse/templates/manage/account/publishing.html:236 +#: warehouse/templates/manage/account/publishing.html:266 +#: warehouse/templates/manage/account/publishing.html:338 msgid "The project (on PyPI) that will be created when this publisher is used" msgstr "" @@ -4340,19 +4386,25 @@ msgid "" msgstr "" #: warehouse/templates/manage/account/publishing.html:110 +#: warehouse/templates/manage/account/publishing.html:217 #: warehouse/templates/manage/project/publishing.html:93 +#: warehouse/templates/manage/project/publishing.html:185 msgid "Environment name" msgstr "" #: warehouse/templates/manage/account/publishing.html:114 -#: warehouse/templates/manage/account/publishing.html:191 +#: warehouse/templates/manage/account/publishing.html:221 +#: warehouse/templates/manage/account/publishing.html:293 #: warehouse/templates/manage/project/publishing.html:97 -#: warehouse/templates/manage/project/publishing.html:159 +#: warehouse/templates/manage/project/publishing.html:189 +#: warehouse/templates/manage/project/publishing.html:246 msgid "(optional)" msgstr "" #: warehouse/templates/manage/account/publishing.html:118 +#: warehouse/templates/manage/account/publishing.html:224 #: warehouse/templates/manage/project/publishing.html:101 +#: warehouse/templates/manage/project/publishing.html:192 msgid "release" msgstr "" @@ -4369,11 +4421,13 @@ msgid "" msgstr "" #: warehouse/templates/manage/account/publishing.html:139 -#: warehouse/templates/manage/account/publishing.html:210 -#: warehouse/templates/manage/account/publishing.html:307 +#: warehouse/templates/manage/account/publishing.html:241 +#: warehouse/templates/manage/account/publishing.html:312 +#: warehouse/templates/manage/account/publishing.html:409 #: warehouse/templates/manage/project/publishing.html:122 -#: warehouse/templates/manage/project/publishing.html:178 -#: warehouse/templates/manage/project/publishing.html:253 +#: warehouse/templates/manage/project/publishing.html:209 +#: warehouse/templates/manage/project/publishing.html:265 +#: warehouse/templates/manage/project/publishing.html:340 #: warehouse/templates/manage/project/roles.html:341 #: warehouse/templates/manage/team/roles.html:131 msgid "Add" @@ -4383,26 +4437,99 @@ msgstr "" #: warehouse/templates/manage/project/publishing.html:129 #, python-format msgid "" -"Read more about Google's OpenID Connect support here." msgstr "" +#: warehouse/templates/manage/account/publishing.html:172 +#: warehouse/templates/manage/project/publishing.html:140 +msgid "Namespace" +msgstr "" + #: warehouse/templates/manage/account/publishing.html:177 #: warehouse/templates/manage/project/publishing.html:145 -msgid "email" +msgid "namespace" msgstr "" #: warehouse/templates/manage/account/publishing.html:179 #: warehouse/templates/manage/project/publishing.html:147 +msgid "" +"The GitLab username or GitLab group/subgroup namespace that the project " +"is under" +msgstr "" + +#: warehouse/templates/manage/account/publishing.html:187 +#: warehouse/templates/manage/project/documentation.html:35 +#: warehouse/templates/manage/project/publishing.html:155 +#: warehouse/templates/manage/project/release.html:129 +#: warehouse/templates/pages/stats.html:42 +msgid "Project name" +msgstr "" + +#: warehouse/templates/manage/account/publishing.html:192 +#: warehouse/templates/manage/project/publishing.html:160 +msgid "project" +msgstr "" + +#: warehouse/templates/manage/account/publishing.html:194 +#: warehouse/templates/manage/project/publishing.html:162 +msgid "The name of the GitLab project that contains the publishing workflow" +msgstr "" + +#: warehouse/templates/manage/account/publishing.html:202 +#: warehouse/templates/manage/project/publishing.html:170 +msgid "Workflow file path" +msgstr "" + +#: warehouse/templates/manage/account/publishing.html:207 +#: warehouse/templates/manage/project/publishing.html:175 +msgid ".gitlab-ci.yml" +msgstr "" + +#: warehouse/templates/manage/account/publishing.html:209 +#: warehouse/templates/manage/project/publishing.html:177 +msgid "" +"The file path of the publishing workflow, relative to the project's root." +" This file should exist in the project configured above (external " +"workflows are not supported)." +msgstr "" + +#: warehouse/templates/manage/account/publishing.html:226 +#: warehouse/templates/manage/project/publishing.html:194 +#, python-format +msgid "" +"The name of the GitLab CI/CD environment that " +"the above workflow uses for publishing. This should be configured under " +"the project's settings. While not required, a dedicated publishing " +"environment is strongly encouraged, " +"especially if your project has maintainers with commit " +"access who shouldn't have PyPI publishing access." +msgstr "" + +#: warehouse/templates/manage/account/publishing.html:248 +#: warehouse/templates/manage/project/publishing.html:216 +#, python-format +msgid "" +"Read more about Google's OpenID Connect support here." +msgstr "" + +#: warehouse/templates/manage/account/publishing.html:279 +#: warehouse/templates/manage/project/publishing.html:232 +msgid "email" +msgstr "" + +#: warehouse/templates/manage/account/publishing.html:281 +#: warehouse/templates/manage/project/publishing.html:234 msgid "The email address of the account or service account used to publish." msgstr "" -#: warehouse/templates/manage/account/publishing.html:195 -#: warehouse/templates/manage/project/publishing.html:163 +#: warehouse/templates/manage/account/publishing.html:297 +#: warehouse/templates/manage/project/publishing.html:250 msgid "subject" msgstr "" -#: warehouse/templates/manage/account/publishing.html:203 +#: warehouse/templates/manage/account/publishing.html:305 #, python-format msgid "" "The subject is the numeric ID that represents the principal making the " @@ -4410,86 +4537,86 @@ msgid "" "identity used for publishing. More details here." msgstr "" -#: warehouse/templates/manage/account/publishing.html:250 -#: warehouse/templates/manage/project/publishing.html:196 +#: warehouse/templates/manage/account/publishing.html:352 +#: warehouse/templates/manage/project/publishing.html:283 msgid "my-organization" msgstr "" -#: warehouse/templates/manage/account/publishing.html:258 -#: warehouse/templates/manage/project/publishing.html:204 +#: warehouse/templates/manage/account/publishing.html:360 +#: warehouse/templates/manage/project/publishing.html:291 msgid "The ActiveState organization name that owns the project" msgstr "" -#: warehouse/templates/manage/account/publishing.html:272 -#: warehouse/templates/manage/project/publishing.html:218 +#: warehouse/templates/manage/account/publishing.html:374 +#: warehouse/templates/manage/project/publishing.html:305 msgid "my-project" msgstr "" -#: warehouse/templates/manage/account/publishing.html:280 -#: warehouse/templates/manage/project/publishing.html:226 +#: warehouse/templates/manage/account/publishing.html:382 +#: warehouse/templates/manage/project/publishing.html:313 msgid "The ActiveState project that will build your Python artifact." msgstr "" -#: warehouse/templates/manage/account/publishing.html:288 -#: warehouse/templates/manage/project/publishing.html:234 +#: warehouse/templates/manage/account/publishing.html:390 +#: warehouse/templates/manage/project/publishing.html:321 msgid "Actor Username" msgstr "" -#: warehouse/templates/manage/account/publishing.html:294 -#: warehouse/templates/manage/project/publishing.html:240 +#: warehouse/templates/manage/account/publishing.html:396 +#: warehouse/templates/manage/project/publishing.html:327 msgid "my-username" msgstr "" -#: warehouse/templates/manage/account/publishing.html:300 -#: warehouse/templates/manage/project/publishing.html:246 +#: warehouse/templates/manage/account/publishing.html:402 +#: warehouse/templates/manage/project/publishing.html:333 msgid "" "The username for the ActiveState account that will trigger the build of " "your Python artifact." msgstr "" -#: warehouse/templates/manage/account/publishing.html:318 +#: warehouse/templates/manage/account/publishing.html:420 msgid "Manage publishers" msgstr "" -#: warehouse/templates/manage/account/publishing.html:328 +#: warehouse/templates/manage/account/publishing.html:430 msgid "Project" msgstr "" -#: warehouse/templates/manage/account/publishing.html:350 +#: warehouse/templates/manage/account/publishing.html:452 msgid "" "No publishers are currently configured. Publishers for existing projects " "can be added in the publishing configuration for each individual project." msgstr "" -#: warehouse/templates/manage/account/publishing.html:362 +#: warehouse/templates/manage/account/publishing.html:464 msgid "Pending project name" msgstr "" -#: warehouse/templates/manage/account/publishing.html:363 -#: warehouse/templates/manage/project/publishing.html:280 +#: warehouse/templates/manage/account/publishing.html:465 +#: warehouse/templates/manage/project/publishing.html:367 msgid "Publisher" msgstr "" -#: warehouse/templates/manage/account/publishing.html:364 -#: warehouse/templates/manage/project/publishing.html:281 +#: warehouse/templates/manage/account/publishing.html:466 +#: warehouse/templates/manage/project/publishing.html:368 msgid "Details" msgstr "" -#: warehouse/templates/manage/account/publishing.html:376 +#: warehouse/templates/manage/account/publishing.html:478 msgid "" "No pending publishers are currently configured. Publishers for projects " "that don't exist yet can be added below." msgstr "" -#: warehouse/templates/manage/account/publishing.html:384 +#: warehouse/templates/manage/account/publishing.html:486 msgid "Add a new pending publisher" msgstr "" -#: warehouse/templates/manage/account/publishing.html:387 +#: warehouse/templates/manage/account/publishing.html:489 msgid "You can use this page to register \"pending\" trusted publishers." msgstr "" -#: warehouse/templates/manage/account/publishing.html:393 +#: warehouse/templates/manage/account/publishing.html:495 #, python-format msgid "" "These publishers behave similarly to trusted publishers registered " @@ -4500,8 +4627,8 @@ msgid "" "trusted publishers here." msgstr "" -#: warehouse/templates/manage/account/publishing.html:432 -#: warehouse/templates/manage/project/publishing.html:328 +#: warehouse/templates/manage/account/publishing.html:535 +#: warehouse/templates/manage/project/publishing.html:416 #, python-format msgid "" "You must first enable two-factor authentication " @@ -5517,12 +5644,6 @@ msgstr "" msgid "Destroy Documentation for project" msgstr "" -#: warehouse/templates/manage/project/documentation.html:35 -#: warehouse/templates/manage/project/release.html:129 -#: warehouse/templates/pages/stats.html:42 -msgid "Project name" -msgstr "" - #: warehouse/templates/manage/project/documentation.html:39 msgid "Project documentation" msgstr "" @@ -5721,7 +5842,7 @@ msgstr "" msgid "Back to projects" msgstr "" -#: warehouse/templates/manage/project/publishing.html:171 +#: warehouse/templates/manage/project/publishing.html:258 #, python-format msgid "" "The subject is the numeric ID that represents the principal making the " @@ -5730,20 +5851,20 @@ msgid "" "here." msgstr "" -#: warehouse/templates/manage/project/publishing.html:272 +#: warehouse/templates/manage/project/publishing.html:359 msgid "Manage current publishers" msgstr "" -#: warehouse/templates/manage/project/publishing.html:276 +#: warehouse/templates/manage/project/publishing.html:363 #, python-format msgid "OpenID Connect publishers associated with %(project_name)s" msgstr "" -#: warehouse/templates/manage/project/publishing.html:292 +#: warehouse/templates/manage/project/publishing.html:379 msgid "No publishers are currently configured." msgstr "" -#: warehouse/templates/manage/project/publishing.html:297 +#: warehouse/templates/manage/project/publishing.html:384 msgid "Add a new publisher" msgstr "" diff --git a/warehouse/manage/views/__init__.py b/warehouse/manage/views/__init__.py index aac989a582aa..c6155f39dd6f 100644 --- a/warehouse/manage/views/__init__.py +++ b/warehouse/manage/views/__init__.py @@ -104,12 +104,14 @@ ActiveStatePublisherForm, DeletePublisherForm, GitHubPublisherForm, + GitLabPublisherForm, GooglePublisherForm, ) from warehouse.oidc.interfaces import TooManyOIDCRegistrations from warehouse.oidc.models import ( ActiveStatePublisher, GitHubPublisher, + GitLabPublisher, GooglePublisher, OIDCPublisher, ) @@ -1145,6 +1147,7 @@ def __init__(self, project, request): self.request.POST, api_token=self.request.registry.settings.get("github.token"), ) + self.gitlab_publisher_form = GitLabPublisherForm(self.request.POST) self.google_publisher_form = GooglePublisherForm(self.request.POST) self.activestate_publisher_form = ActiveStatePublisherForm(self.request.POST) @@ -1183,12 +1186,16 @@ def default_response(self): return { "project": self.project, "github_publisher_form": self.github_publisher_form, + "gitlab_publisher_form": self.gitlab_publisher_form, "google_publisher_form": self.google_publisher_form, "activestate_publisher_form": self.activestate_publisher_form, "disabled": { "GitHub": self.request.flags.disallow_oidc( AdminFlagValue.DISALLOW_GITHUB_OIDC ), + "GitLab": self.request.flags.disallow_oidc( + AdminFlagValue.DISALLOW_GITLAB_OIDC + ), "Google": self.request.flags.disallow_oidc( AdminFlagValue.DISALLOW_GOOGLE_OIDC ), @@ -1324,6 +1331,118 @@ def add_github_oidc_publisher(self): return HTTPSeeOther(self.request.path) + @view_config( + request_method="POST", + request_param=GitLabPublisherForm.__params__, + ) + def add_gitlab_oidc_publisher(self): + if self.request.flags.disallow_oidc(AdminFlagValue.DISALLOW_GITLAB_OIDC): + self.request.session.flash( + self.request._( + "GitLab-based trusted publishing is temporarily disabled. " + "See https://pypi.org/help#admin-intervention for details." + ), + queue="error", + ) + return self.default_response + + self.metrics.increment( + "warehouse.oidc.add_publisher.attempt", tags=["publisher:GitLab"] + ) + + try: + self._check_ratelimits() + except TooManyOIDCRegistrations as exc: + self.metrics.increment( + "warehouse.oidc.add_publisher.ratelimited", tags=["publisher:GitLab"] + ) + return HTTPTooManyRequests( + self.request._( + "There have been too many attempted trusted publisher " + "registrations. Try again later." + ), + retry_after=exc.resets_in.total_seconds(), + ) + + self._hit_ratelimits() + + response = self.default_response + form = response["gitlab_publisher_form"] + + if not form.validate(): + self.request.session.flash( + self.request._("The trusted publisher could not be registered"), + queue="error", + ) + return response + + # GitLab OIDC publishers are unique on the tuple of + # (namespace, project, workflow_filepath, environment), + # so we check for an already registered one before creating. + publisher = ( + self.request.db.query(GitLabPublisher) + .filter( + GitLabPublisher.namespace == form.namespace.data, + GitLabPublisher.project == form.project.data, + GitLabPublisher.workflow_filepath == form.workflow_filepath.data, + GitLabPublisher.environment == form.normalized_environment, + ) + .one_or_none() + ) + if publisher is None: + publisher = GitLabPublisher( + namespace=form.namespace.data, + project=form.project.data, + workflow_filepath=form.workflow_filepath.data, + environment=form.normalized_environment, + ) + + self.request.db.add(publisher) + + # Each project has a unique set of OIDC publishers; the same + # publisher can't be registered to the project more than once. + if publisher in self.project.oidc_publishers: + self.request.session.flash( + self.request._( + f"{publisher} is already registered with {self.project.name}" + ), + queue="error", + ) + return response + + for user in self.project.users: + send_trusted_publisher_added_email( + self.request, + user, + project_name=self.project.name, + publisher=publisher, + ) + + self.project.oidc_publishers.append(publisher) + + self.project.record_event( + tag=EventTag.Project.OIDCPublisherAdded, + request=self.request, + additional={ + "publisher": publisher.publisher_name, + "id": str(publisher.id), + "specifier": str(publisher), + "url": publisher.publisher_url(), + "submitted_by": self.request.user.username, + }, + ) + + self.request.session.flash( + f"Added {publisher} in {publisher.publisher_url()} to {self.project.name}", + queue="success", + ) + + self.metrics.increment( + "warehouse.oidc.add_publisher.ok", tags=["publisher:GitLab"] + ) + + return HTTPSeeOther(self.request.path) + @view_config( request_method="POST", request_param=GooglePublisherForm.__params__, diff --git a/warehouse/migrations/versions/81f9f9a60270_add_gitlab_oidc_models.py b/warehouse/migrations/versions/81f9f9a60270_add_gitlab_oidc_models.py new file mode 100644 index 000000000000..0561fd0868a4 --- /dev/null +++ b/warehouse/migrations/versions/81f9f9a60270_add_gitlab_oidc_models.py @@ -0,0 +1,85 @@ +# 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 GitLab OIDC models + +Revision ID: 81f9f9a60270 +Revises: 4d1b4fcc4076 +Create Date: 2024-01-16 17:46:16.443395 +""" + +import sqlalchemy as sa + +from alembic import op + +revision = "81f9f9a60270" +down_revision = "4d1b4fcc4076" + + +def upgrade(): + op.execute( + """ + INSERT INTO admin_flags(id, description, enabled, notify) + VALUES ( + 'disallow-gitlab-oidc', + 'Disallow the GitLab OIDC provider', + TRUE, + FALSE + ) + """ + ) + op.create_table( + "gitlab_oidc_publishers", + sa.Column("id", sa.UUID(), nullable=False), + sa.Column("namespace", sa.String(), nullable=False), + sa.Column("project", sa.String(), nullable=False), + sa.Column("workflow_filepath", sa.String(), nullable=False), + sa.Column("environment", sa.String(), nullable=False), + sa.ForeignKeyConstraint( + ["id"], + ["oidc_publishers.id"], + ), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint( + "namespace", + "project", + "workflow_filepath", + "environment", + name="_gitlab_oidc_publisher_uc", + ), + ) + op.create_table( + "pending_gitlab_oidc_publishers", + sa.Column("id", sa.UUID(), nullable=False), + sa.Column("namespace", sa.String(), nullable=False), + sa.Column("project", sa.String(), nullable=False), + sa.Column("workflow_filepath", sa.String(), nullable=False), + sa.Column("environment", sa.String(), nullable=False), + sa.ForeignKeyConstraint( + ["id"], + ["pending_oidc_publishers.id"], + ), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint( + "namespace", + "project", + "workflow_filepath", + "environment", + name="_pending_gitlab_oidc_publisher_uc", + ), + ) + + +def downgrade(): + op.execute("DELETE FROM admin_flags WHERE id = 'disallow-gitlab-oidc'") + op.drop_table("pending_gitlab_oidc_publishers") + op.drop_table("gitlab_oidc_publishers") diff --git a/warehouse/oidc/__init__.py b/warehouse/oidc/__init__.py index 91f15025e497..976a25f0e48d 100644 --- a/warehouse/oidc/__init__.py +++ b/warehouse/oidc/__init__.py @@ -18,6 +18,7 @@ from warehouse.oidc.utils import ( ACTIVESTATE_OIDC_ISSUER_URL, GITHUB_OIDC_ISSUER_URL, + GITLAB_OIDC_ISSUER_URL, GOOGLE_OIDC_ISSUER_URL, ) @@ -36,6 +37,15 @@ def includeme(config): IOIDCPublisherService, name="github", ) + config.register_service_factory( + OIDCPublisherServiceFactory( + publisher="gitlab", + issuer_url=GITLAB_OIDC_ISSUER_URL, + service_class=oidc_publisher_service_class, + ), + IOIDCPublisherService, + name="gitlab", + ) config.register_service_factory( OIDCPublisherServiceFactory( publisher="google", diff --git a/warehouse/oidc/forms/__init__.py b/warehouse/oidc/forms/__init__.py index c37d8e851b1d..e41f6f6e557e 100644 --- a/warehouse/oidc/forms/__init__.py +++ b/warehouse/oidc/forms/__init__.py @@ -16,12 +16,15 @@ PendingActiveStatePublisherForm, ) from warehouse.oidc.forms.github import GitHubPublisherForm, PendingGitHubPublisherForm +from warehouse.oidc.forms.gitlab import GitLabPublisherForm, PendingGitLabPublisherForm from warehouse.oidc.forms.google import GooglePublisherForm, PendingGooglePublisherForm __all__ = [ "DeletePublisherForm", "GitHubPublisherForm", "PendingGitHubPublisherForm", + "GitLabPublisherForm", + "PendingGitLabPublisherForm", "GooglePublisherForm", "PendingGooglePublisherForm", "ActiveStatePublisherForm", diff --git a/warehouse/oidc/forms/gitlab.py b/warehouse/oidc/forms/gitlab.py new file mode 100644 index 000000000000..1b2eb07bcf4f --- /dev/null +++ b/warehouse/oidc/forms/gitlab.py @@ -0,0 +1,104 @@ +# 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 wtforms + +from warehouse import forms +from warehouse.i18n import localize as _ +from warehouse.oidc.forms._core import PendingPublisherMixin + +# https://docs.gitlab.com/ee/user/reserved_names.html#limitations-on-project-and-group-names +_VALID_GITLAB_PROJECT = re.compile(r"^[a-zA-Z0-9][a-zA-Z0-9-_.]*$") +_VALID_GITLAB_NAMESPACE = re.compile(r"^[a-zA-Z0-9][a-zA-Z0-9-_.]*$") +_VALID_GITLAB_ENVIRONMENT = re.compile(r"^[a-zA-Z0-9\-_/${} ]+$") + + +class GitLabPublisherBase(forms.Form): + __params__ = ["namespace", "project", "workflow_filepath", "environment"] + + namespace = wtforms.StringField( + validators=[ + wtforms.validators.InputRequired( + message=_("Specify GitLab namespace (username or group/subgroup)"), + ), + wtforms.validators.Regexp( + _VALID_GITLAB_NAMESPACE, + message=_("Invalid GitLab username or group/subgroup name."), + ), + ] + ) + + project = wtforms.StringField( + validators=[ + wtforms.validators.InputRequired(message=_("Specify project name")), + wtforms.validators.Regexp( + _VALID_GITLAB_PROJECT, message=_("Invalid project name") + ), + ] + ) + + workflow_filepath = wtforms.StringField( + validators=[ + wtforms.validators.InputRequired(message=_("Specify workflow filepath")) + ] + ) + + environment = wtforms.StringField( + validators=[ + wtforms.validators.Optional(), + wtforms.validators.Regexp( + _VALID_GITLAB_ENVIRONMENT, message=_("Invalid environment name") + ), + ] + ) + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + def validate_workflow_filepath(self, field): + workflow_filepath = field.data + + if not ( + workflow_filepath.endswith(".yml") or workflow_filepath.endswith(".yaml") + ): + raise wtforms.validators.ValidationError( + _("Workflow file path must end with .yml or .yaml") + ) + if workflow_filepath.startswith("/") or workflow_filepath.endswith("/"): + raise wtforms.validators.ValidationError( + _("Workflow file path cannot start or end with /") + ) + + @property + def normalized_environment(self): + # NOTE: We explicitly do not compare `self.environment.data` to None, + # since it might also be falsey via an empty string (or might be + # only whitespace, which we also treat as a None case). + return ( + self.environment.data + if self.environment.data and not self.environment.data.isspace() + else "" + ) + + +class PendingGitLabPublisherForm(GitLabPublisherBase, PendingPublisherMixin): + __params__ = GitLabPublisherBase.__params__ + ["project_name"] + + def __init__(self, *args, project_factory, **kwargs): + super().__init__(*args, **kwargs) + self._project_factory = project_factory + + +class GitLabPublisherForm(GitLabPublisherBase): + pass diff --git a/warehouse/oidc/models/__init__.py b/warehouse/oidc/models/__init__.py index 8417b6fd8ea8..a125c451a58e 100644 --- a/warehouse/oidc/models/__init__.py +++ b/warehouse/oidc/models/__init__.py @@ -16,15 +16,18 @@ PendingActiveStatePublisher, ) from warehouse.oidc.models.github import GitHubPublisher, PendingGitHubPublisher +from warehouse.oidc.models.gitlab import GitLabPublisher, PendingGitLabPublisher from warehouse.oidc.models.google import GooglePublisher, PendingGooglePublisher __all__ = [ "OIDCPublisher", "PendingOIDCPublisher", "PendingGitHubPublisher", + "PendingGitLabPublisher", "PendingGooglePublisher", "PendingActiveStatePublisher", "GitHubPublisher", + "GitLabPublisher", "GooglePublisher", "ActiveStatePublisher", ] diff --git a/warehouse/oidc/models/gitlab.py b/warehouse/oidc/models/gitlab.py new file mode 100644 index 000000000000..f119b2c3ba2b --- /dev/null +++ b/warehouse/oidc/models/gitlab.py @@ -0,0 +1,284 @@ +# 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 + +from sqlalchemy import ForeignKey, String, UniqueConstraint +from sqlalchemy.dialects.postgresql import UUID +from sqlalchemy.orm import Query, mapped_column +from sqlalchemy.sql.expression import func, literal + +from warehouse.oidc.errors import InvalidPublisherError +from warehouse.oidc.interfaces import SignedClaims +from warehouse.oidc.models._core import ( + CheckClaimCallable, + OIDCPublisher, + PendingOIDCPublisher, + check_claim_binary, +) + + +def _check_ci_config_ref_uri(ground_truth, signed_claim, all_signed_claims): + # We expect a string formatted as follows: + # gitlab.com/OWNER/REPO//WORKFLOW_PATH/WORKFLOW_FILE.yml@REF + # where REF is the value of the `ref_path` claim. + + # Defensive: GitLab should never give us an empty ci_config_ref_uri, + # but we check for one anyway just in case. + if not signed_claim: + raise InvalidPublisherError("The ci_config_ref_uri claim is empty") + + # Same defensive check as above but for ref_path and sha. + ref_path = all_signed_claims.get("ref_path") + sha = all_signed_claims.get("sha") + if not (ref_path and sha): + raise InvalidPublisherError("The ref_path and sha claims are empty") + + expected = {f"{ground_truth}@{_ref}" for _ref in [ref_path, sha] if _ref} + if signed_claim not in expected: + raise InvalidPublisherError( + "The ci_config_ref_uri claim does not match, expecting one of " + f"{sorted(expected)!r}, got {signed_claim!r}" + ) + + return True + + +def _check_environment(ground_truth, signed_claim, all_signed_claims): + # When there is an environment, we expect a string. + # For tokens that are generated outside of an environment, the claim will + # be missing. + + # If we haven't set an environment name for the publisher, we don't need to + # check this claim + if ground_truth == "": + return True + + # Defensive: GitLab might give us an empty environment if this token wasn't + # generated from within an environment, in which case the check should + # fail. + if not signed_claim: + return False + + return ground_truth == signed_claim + + +def _check_sub(ground_truth, signed_claim, _all_signed_claims): + # We expect a string formatted as follows: + # project_path:NAMESPACE/PROJECT[:OPTIONAL-STUFF] + # where :OPTIONAL-STUFF is a concatenation of other job context + # metadata. We currently lack the ground context to verify that + # additional metadata, so we limit our verification to just the + # NAMESPACE/PROJECT component. + + # Defensive: GitLab should never give us an empty subject. + if not signed_claim: + return False + + components = signed_claim.split(":") + if len(components) < 2: + return False + + namespace, project, *_ = components + if not namespace or not project: + return False + + # The sub claim is case-insensitive + return f"{namespace}:{project}".lower() == ground_truth.lower() + + +class GitLabPublisherMixin: + """ + Common functionality for both pending and concrete GitLab OIDC publishers. + """ + + namespace = mapped_column(String, nullable=False) + project = mapped_column(String, nullable=False) + workflow_filepath = mapped_column(String, nullable=False) + environment = mapped_column(String, nullable=False) + + __required_verifiable_claims__: dict[str, CheckClaimCallable[Any]] = { + "sub": _check_sub, + "project_path": check_claim_binary(str.__eq__), + "ci_config_ref_uri": _check_ci_config_ref_uri, + } + + __required_unverifiable_claims__: set[str] = {"ref_path", "sha"} + + __optional_verifiable_claims__: dict[str, CheckClaimCallable[Any]] = { + "environment": _check_environment, + } + + __unchecked_claims__ = { + "namespace_id", + "namespace_path", + "user_id", + "user_login", + "user_email", + "user_identities", + "pipeline_id", + "pipeline_source", + "job_id", + "ref", + "ref_type", + "ref_protected", + "environment_protected", + "deployment_tier", + "environment_action", + "runner_id", + "runner_environment", + "ci_config_sha", + "project_visibility", + "jti", + } + + @staticmethod + def __lookup_all__(klass, signed_claims: SignedClaims) -> Query | None: + # This lookup requires the environment claim to be present; + # if it isn't, bail out early. + if not (environment := signed_claims.get("environment")): + return None + + project_path = signed_claims["project_path"] + ci_config_ref_prefix = f"gitlab.com/{project_path}//" + ci_config_ref = signed_claims["ci_config_ref_uri"].removeprefix( + ci_config_ref_prefix + ) + namespace, project = project_path.rsplit("/", 1) + + return ( + Query(klass) + .filter_by( + namespace=namespace, + project=project, + environment=environment, + ) + .filter( + literal(ci_config_ref).like(func.concat(klass.workflow_filepath, "%")) + ) + ) + + @staticmethod + def __lookup_no_environment__(klass, signed_claims: SignedClaims) -> Query | None: + project_path = signed_claims["project_path"] + ci_config_ref_prefix = f"gitlab.com/{project_path}//" + ci_config_ref = signed_claims["ci_config_ref_uri"].removeprefix( + ci_config_ref_prefix + ) + namespace, project = project_path.rsplit("/", 1) + + return ( + Query(klass) + .filter_by( + namespace=namespace, + project=project, + environment="", + ) + .filter( + literal(ci_config_ref).like(func.concat(klass.workflow_filepath, "%")) + ) + ) + + __lookup_strategies__ = [ + __lookup_all__, + __lookup_no_environment__, + ] + + @property + def project_path(self): + return f"{self.namespace}/{self.project}" + + @property + def sub(self): + return f"project_path:{self.project_path}" + + @property + def ci_config_ref_uri(self): + return f"gitlab.com/{self.project_path}//{self.workflow_filepath}" + + @property + def publisher_name(self): + return "GitLab" + + def publisher_url(self, claims=None): + base = f"https://gitlab.com/{self.project_path}" + return f"{base}/commit/{claims['sha']}" if claims else base + + def stored_claims(self, claims=None): + claims = claims if claims else {} + return {"ref_path": claims.get("ref_path"), "sha": claims.get("sha")} + + def __str__(self): + return self.workflow_filepath + + +class GitLabPublisher(GitLabPublisherMixin, OIDCPublisher): + __tablename__ = "gitlab_oidc_publishers" + __mapper_args__ = {"polymorphic_identity": "gitlab_oidc_publishers"} + __table_args__ = ( + UniqueConstraint( + "namespace", + "project", + "workflow_filepath", + "environment", + name="_gitlab_oidc_publisher_uc", + ), + ) + + id = mapped_column( + UUID(as_uuid=True), ForeignKey(OIDCPublisher.id), primary_key=True + ) + + +class PendingGitLabPublisher(GitLabPublisherMixin, PendingOIDCPublisher): + __tablename__ = "pending_gitlab_oidc_publishers" + __mapper_args__ = {"polymorphic_identity": "pending_gitlab_oidc_publishers"} + __table_args__ = ( + UniqueConstraint( + "namespace", + "project", + "workflow_filepath", + "environment", + name="_pending_gitlab_oidc_publisher_uc", + ), + ) + + id = mapped_column( + UUID(as_uuid=True), ForeignKey(PendingOIDCPublisher.id), primary_key=True + ) + + def reify(self, session): + """ + Returns a `GitLabPublisher` for this `PendingGitLabPublisher`, + deleting the `PendingGitLabPublisher` in the process. + """ + + maybe_publisher = ( + session.query(GitLabPublisher) + .filter( + GitLabPublisher.namespace == self.namespace, + GitLabPublisher.project == self.project, + GitLabPublisher.workflow_filepath == self.workflow_filepath, + GitLabPublisher.environment == self.environment, + ) + .one_or_none() + ) + + publisher = maybe_publisher or GitLabPublisher( + namespace=self.namespace, + project=self.project, + workflow_filepath=self.workflow_filepath, + environment=self.environment, + ) + + session.delete(self) + return publisher diff --git a/warehouse/oidc/utils.py b/warehouse/oidc/utils.py index 690203faabea..155275748077 100644 --- a/warehouse/oidc/utils.py +++ b/warehouse/oidc/utils.py @@ -22,32 +22,38 @@ from warehouse.oidc.models import ( ActiveStatePublisher, GitHubPublisher, + GitLabPublisher, GooglePublisher, OIDCPublisher, PendingActiveStatePublisher, PendingGitHubPublisher, + PendingGitLabPublisher, PendingGooglePublisher, PendingOIDCPublisher, ) GITHUB_OIDC_ISSUER_URL = "https://token.actions.githubusercontent.com" +GITLAB_OIDC_ISSUER_URL = "https://gitlab.com" GOOGLE_OIDC_ISSUER_URL = "https://accounts.google.com" ACTIVESTATE_OIDC_ISSUER_URL = "https://platform.activestate.com/api/v1/oauth/oidc" OIDC_ISSUER_SERVICE_NAMES = { GITHUB_OIDC_ISSUER_URL: "github", + GITLAB_OIDC_ISSUER_URL: "gitlab", GOOGLE_OIDC_ISSUER_URL: "google", ACTIVESTATE_OIDC_ISSUER_URL: "activestate", } OIDC_ISSUER_ADMIN_FLAGS = { GITHUB_OIDC_ISSUER_URL: AdminFlagValue.DISALLOW_GITHUB_OIDC, + GITLAB_OIDC_ISSUER_URL: AdminFlagValue.DISALLOW_GITLAB_OIDC, GOOGLE_OIDC_ISSUER_URL: AdminFlagValue.DISALLOW_GOOGLE_OIDC, ACTIVESTATE_OIDC_ISSUER_URL: AdminFlagValue.DISALLOW_ACTIVESTATE_OIDC, } OIDC_ISSUER_URLS = { GITHUB_OIDC_ISSUER_URL, + GITLAB_OIDC_ISSUER_URL, GOOGLE_OIDC_ISSUER_URL, ACTIVESTATE_OIDC_ISSUER_URL, } @@ -56,6 +62,7 @@ str, dict[bool, type[OIDCPublisher | PendingOIDCPublisher]] ] = { GITHUB_OIDC_ISSUER_URL: {False: GitHubPublisher, True: PendingGitHubPublisher}, + GITLAB_OIDC_ISSUER_URL: {False: GitLabPublisher, True: PendingGitLabPublisher}, GOOGLE_OIDC_ISSUER_URL: {False: GooglePublisher, True: PendingGooglePublisher}, ACTIVESTATE_OIDC_ISSUER_URL: { False: ActiveStatePublisher, diff --git a/warehouse/templates/manage/account/publishing.html b/warehouse/templates/manage/account/publishing.html index 6e6d56c4c4f8..0433d9c5fe64 100644 --- a/warehouse/templates/manage/account/publishing.html +++ b/warehouse/templates/manage/account/publishing.html @@ -141,6 +141,108 @@ {% endmacro %} +{% macro gitlab_form(request, pending_gitlab_publisher_form) %} +

+ {% trans href="https://docs.gitlab.com/ee/ci/secrets/id_token_authentication.html" %} + Read more about GitLab CI/CD OpenID Connect support here. + {% endtrans %} +

+ + {{ form_error_anchor(pending_gitlab_publisher_form) }} +
+ + {{ form_errors(pending_gitlab_publisher_form) }} +
+ + {{ pending_gitlab_publisher_form.project_name(placeholder=gettext("project name"), autocomplete="off", autocapitalize="off", spellcheck="false", class_="form-group__field", aria_describedby="project_name-errors") }} +

+ {% trans %}The project (on PyPI) that will be created when this publisher is used{% endtrans %} +

+
+ {{ field_errors(pending_gitlab_publisher_form.project_name) }} +
+
+
+ + {{ pending_gitlab_publisher_form.namespace(placeholder=gettext("namespace"), autocomplete="off", autocapitalize="off", spellcheck="false", class_="form-group__field", aria_describedby="namespace-errors") }} +

+ {% trans %}The GitLab username or GitLab group/subgroup namespace that the project is under{% endtrans %} +

+
+ {{ field_errors(pending_gitlab_publisher_form.namespace) }} +
+
+
+ + {{ pending_gitlab_publisher_form.project(placeholder=gettext("project"), autocomplete="off", autocapitalize="off", spellcheck="false", class_="form-group__field", **{"aria-describedby":"project-errors"}) }} +

+ {% trans %}The name of the GitLab project that contains the publishing workflow{% endtrans %} +

+
+ {{ field_errors(pending_gitlab_publisher_form.project) }} +
+
+
+ + {{ pending_gitlab_publisher_form.workflow_filepath(placeholder=gettext(".gitlab-ci.yml"), class_="form-group__field", autocomplete="off", **{"aria-describedby":"workflow_filepath-errors"}) }} +

+ {% trans %}The file path of the publishing workflow, relative to the project's root. This file should exist in the project configured above (external workflows are not supported).{% endtrans %} +

+
+ {{ field_errors(pending_gitlab_publisher_form.workflow_filepath) }} +
+
+
+ + {{ pending_gitlab_publisher_form.environment(placeholder=gettext("release"), class_="form-group__field", autocomplete="off", **{"aria-describedby":"environment-errors"}) }} +

+ {% trans href="https://docs.gitlab.com/ee/ci/environments/" %} + The name of the GitLab CI/CD environment + that the above workflow uses for publishing. This should be + configured under the project's settings. While not required, a + dedicated publishing environment is strongly + encouraged, especially if your project has + maintainers with commit access who shouldn't have PyPI publishing + access. + {% endtrans %} +

+
+ {{ field_errors(pending_gitlab_publisher_form.environment) }} +
+
+
+ +
+
+{% endmacro %} + {% macro google_form(request, pending_google_publisher_form) %}

{% trans href="https://cloud.google.com/iam/docs/service-account-creds" %} @@ -403,6 +505,7 @@

{% trans %}Add a new pending publisher{% endtrans %}

{% if request.user.has_two_factor %} {% set publishers = [ ("GitHub", github_form(request, pending_github_publisher_form)), + ("GitLab", gitlab_form(request, pending_gitlab_publisher_form)), ("Google", google_form(request, pending_google_publisher_form)), ("ActiveState", activestate_form(request, pending_activestate_publisher_form)), ] diff --git a/warehouse/templates/manage/manage_base.html b/warehouse/templates/manage/manage_base.html index 9a798b438a65..5f8ef2c1ca8b 100644 --- a/warehouse/templates/manage/manage_base.html +++ b/warehouse/templates/manage/manage_base.html @@ -545,6 +545,15 @@ {% else %} ({% trans %}Any{% endtrans %}) {% endif %} + {% elif publisher.publisher_name == "GitLab" %} + Project: {{ publisher.project_path }}
+ Workflow: {{ publisher.workflow_filepath }}
+ Environment name: + {% if publisher.environment %} + {{ publisher.environment }} + {% else %} + ({% trans %}Any{% endtrans %}) + {% endif %} {% elif publisher.publisher_name == "Google" %} Email: {{ publisher.email }}
Subject: diff --git a/warehouse/templates/manage/project/publishing.html b/warehouse/templates/manage/project/publishing.html index 9e43da7504e4..7028693bdf39 100644 --- a/warehouse/templates/manage/project/publishing.html +++ b/warehouse/templates/manage/project/publishing.html @@ -124,6 +124,93 @@ {% endmacro %} +{% macro gitlab_form(request, gitlab_publisher_form) %} +

+ {% trans href="https://docs.gitlab.com/ee/ci/secrets/id_token_authentication.html" %} + Read more about GitLab CI/CD OpenID Connect support here. + {% endtrans %} +

+ + {{ form_error_anchor(gitlab_publisher_form) }} +
+ + {{ form_errors(gitlab_publisher_form) }} +
+ + {{ gitlab_publisher_form.namespace(placeholder=gettext("namespace"), autocomplete="off", autocapitalize="off", spellcheck="false", class_="form-group__field", aria_describedby="namespace-errors") }} +

+ {% trans %}The GitLab username or GitLab group/subgroup namespace that the project is under{% endtrans %} +

+
+ {{ field_errors(gitlab_publisher_form.namespace) }} +
+
+
+ + {{ gitlab_publisher_form.project(placeholder=gettext("project"), autocomplete="off", autocapitalize="off", spellcheck="false", class_="form-group__field", **{"aria-describedby":"project-errors"}) }} +

+ {% trans %}The name of the GitLab project that contains the publishing workflow{% endtrans %} +

+
+ {{ field_errors(gitlab_publisher_form.project) }} +
+
+
+ + {{ gitlab_publisher_form.workflow_filepath(placeholder=gettext(".gitlab-ci.yml"), class_="form-group__field", autocomplete="off", **{"aria-describedby":"workflow_filepath-errors"}) }} +

+ {% trans %}The file path of the publishing workflow, relative to the project's root. This file should exist in the project configured above (external workflows are not supported).{% endtrans %} +

+
+ {{ field_errors(gitlab_publisher_form.workflow_filepath) }} +
+
+
+ + {{ gitlab_publisher_form.environment(placeholder=gettext("release"), class_="form-group__field", autocomplete="off", **{"aria-describedby":"environment-errors"}) }} +

+ {% trans href="https://docs.gitlab.com/ee/ci/environments/" %} + The name of the GitLab CI/CD environment + that the above workflow uses for publishing. This should be + configured under the project's settings. While not required, a + dedicated publishing environment is strongly + encouraged, especially if your project has + maintainers with commit access who shouldn't have PyPI publishing + access. + {% endtrans %} +

+
+ {{ field_errors(gitlab_publisher_form.environment) }} +
+
+
+ +
+
+{% endmacro %} + {% macro google_form(request, google_publisher_form) %}

{% trans href="https://cloud.google.com/iam/docs/service-account-creds" %} @@ -300,6 +387,7 @@

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

{% set publishers = [ ("GitHub", github_form(request, github_publisher_form)), + ("GitLab", gitlab_form(request, gitlab_publisher_form)), ("Google", google_form(request, google_publisher_form)), ("ActiveState", activestate_form(request, activestate_publisher_form)), ]