diff --git a/tests/unit/email/test_init.py b/tests/unit/email/test_init.py index 9c20108353b9..68380db2571a 100644 --- a/tests/unit/email/test_init.py +++ b/tests/unit/email/test_init.py @@ -6218,3 +6218,99 @@ def test_pep625_emails( }, ) ] + + def test_environment_ignored_in_trusted_publisher_emails( + self, pyramid_request, pyramid_config, monkeypatch + ): + template_name = "environment-ignored-in-trusted-publisher" + stub_user_owner = pretend.stub( + id="id_owner", + username="username_owner", + name="", + email="email@example.com", + primary_email=pretend.stub(email="email@example.com", verified=True), + ) + subject_renderer = pyramid_config.testing_add_renderer( + f"email/{template_name}/subject.txt" + ) + subject_renderer.string_response = "Email Subject" + body_renderer = pyramid_config.testing_add_renderer( + f"email/{template_name}/body.txt" + ) + body_renderer.string_response = "Email Body" + html_renderer = pyramid_config.testing_add_renderer( + f"email/{template_name}/body.html" + ) + html_renderer.string_response = "Email HTML Body" + + send_email = pretend.stub( + delay=pretend.call_recorder(lambda *args, **kwargs: None) + ) + pyramid_request.task = pretend.call_recorder(lambda *args, **kwargs: send_email) + monkeypatch.setattr(email, "send_email", send_email) + + pyramid_request.db = pretend.stub( + query=lambda a: pretend.stub( + filter=lambda *a: pretend.stub( + one=lambda: pretend.stub(user_id=stub_user_owner.id) + ) + ), + ) + fakepublisher = pretend.stub( + publisher_name="fakepublisher", + repository_owner="fakeowner", + repository_name="fakerepository", + environment="", + ) + fakeenvironment = "fakeenvironment" + pyramid_request.user = stub_user_owner + pyramid_request.registry.settings = {"mail.sender": "noreply@example.com"} + + project_name = "test_project" + result = email.send_environment_ignored_in_trusted_publisher_email( + pyramid_request, + [stub_user_owner], + project_name=project_name, + publisher=fakepublisher, + environment_name=fakeenvironment, + ) + + assert result == { + "project_name": project_name, + "publisher": fakepublisher, + "environment_name": fakeenvironment, + } + subject_renderer.assert_() + body_renderer.assert_() + html_renderer.assert_( + project_name=project_name, + publisher=fakepublisher, + environment_name=fakeenvironment, + ) + assert pyramid_request.task.calls == [ + pretend.call(send_email), + ] + assert send_email.delay.calls == [ + pretend.call( + f"{stub_user_owner.username} <{stub_user_owner.email}>", + { + "sender": None, + "subject": "Email Subject", + "body_text": "Email Body", + "body_html": ( + "\n\n" + "

Email HTML Body

\n\n" + ), + }, + { + "tag": "account:email:sent", + "user_id": stub_user_owner.id, + "additional": { + "from_": "noreply@example.com", + "to": stub_user_owner.email, + "subject": "Email Subject", + "redact_ip": False, + }, + }, + ), + ] diff --git a/tests/unit/manage/test_views.py b/tests/unit/manage/test_views.py index fb16a5a325ec..59b05dfea9f4 100644 --- a/tests/unit/manage/test_views.py +++ b/tests/unit/manage/test_views.py @@ -96,7 +96,6 @@ class TestManageUnverifiedAccount: - def test_manage_account(self, monkeypatch): user_service = pretend.stub() name = pretend.stub() @@ -6467,6 +6466,514 @@ def test_manage_project_oidc_publishers_prefill_unknown_provider(self, monkeypat assert all(v is None for _, v in view.github_publisher_form.data.items()) + @pytest.mark.parametrize( + ("publisher", "new_environment_name"), + [ + ( + GitHubPublisher( + repository_name="some-repository", + repository_owner="some-owner", + repository_owner_id="666", + workflow_filename="some-workflow-filename.yml", + environment="", + ), + "fakeenv", + ), + ( + GitLabPublisher( + namespace="some-namespace", + project="some-project", + workflow_filepath="some-workflow-filename.yml", + environment="", + ), + "fakeenv", + ), + ], + ) + def test_manage_project_oidc_publishers_constrain_environment( + self, + monkeypatch, + metrics, + db_request, + publisher, + new_environment_name, + ): + owner = UserFactory.create() + db_request.user = owner + + project = ProjectFactory.create(oidc_publishers=[publisher]) + project.record_event = pretend.call_recorder(lambda *a, **kw: None) + RoleFactory.create(user=owner, project=project, role_name="Owner") + + db_request.db.add(publisher) + db_request.db.flush() # To get the id + + db_request.method = "GET" + db_request.POST = MultiDict() + db_request.find_service = lambda *a, **kw: metrics + db_request.session = pretend.stub( + flash=pretend.call_recorder(lambda *a, **kw: None) + ) + db_request.flags = pretend.stub( + disallow_oidc=pretend.call_recorder(lambda f=None: False) + ) + db_request._ = lambda s: s + params = { + "publisher_id": str(publisher.id), + "constrain_environment": new_environment_name, + } + db_request.params = MultiDict(params) + + view = views.ManageOIDCPublisherViews(project, db_request) + + assert isinstance( + view.manage_project_oidc_publisher_constrain_environment(), HTTPSeeOther + ) + assert view.metrics.increment.calls == [ + pretend.call( + "warehouse.oidc.constrain_publisher_environment.attempt", + ), + ] + + # The old publisher is actually removed entirely from the DB + # and replaced by the new constrained publisher. + publishers = db_request.db.query(OIDCPublisher).all() + assert len(publishers) == 1 + constrained_publisher = publishers[0] + assert constrained_publisher.environment == new_environment_name + assert project.oidc_publishers == [constrained_publisher] + + assert project.record_event.calls == [ + pretend.call( + tag=EventTag.Project.OIDCPublisherAdded, + request=db_request, + additional={ + "publisher": constrained_publisher.publisher_name, + "id": str(constrained_publisher.id), + "specifier": str(constrained_publisher), + "url": publisher.publisher_url(), + "submitted_by": db_request.user.username, + }, + ), + pretend.call( + tag=EventTag.Project.OIDCPublisherRemoved, + request=db_request, + additional={ + "publisher": publisher.publisher_name, + "id": str(publisher.id), + "specifier": str(publisher), + "url": publisher.publisher_url(), + "submitted_by": db_request.user.username, + }, + ), + ] + assert db_request.flags.disallow_oidc.calls == [pretend.call()] + assert db_request.session.flash.calls == [ + pretend.call( + f"Trusted publisher for project {project.name!r} has been " + f"constrained to environment {new_environment_name!r}", + queue="success", + ) + ] + + def test_manage_project_oidc_publishers_constrain_environment_shared_publisher( + self, + metrics, + db_request, + ): + publisher = GitHubPublisher( + repository_name="some-repository", + repository_owner="some-owner", + repository_owner_id="666", + workflow_filename="some-workflow-filename.yml", + environment="", + ) + owner = UserFactory.create() + db_request.user = owner + + project = ProjectFactory.create(oidc_publishers=[publisher]) + other_project = ProjectFactory.create(oidc_publishers=[publisher]) + project.record_event = pretend.call_recorder(lambda *a, **kw: None) + RoleFactory.create(user=owner, project=project, role_name="Owner") + + db_request.db.add(publisher) + db_request.db.flush() # To get the id + + db_request.method = "GET" + db_request.POST = MultiDict() + db_request.find_service = lambda *a, **kw: metrics + db_request.session = pretend.stub( + flash=pretend.call_recorder(lambda *a, **kw: None) + ) + db_request.flags = pretend.stub( + disallow_oidc=pretend.call_recorder(lambda f=None: False) + ) + db_request._ = lambda s: s + params = { + "publisher_id": str(publisher.id), + "constrain_environment": "fakeenv", + } + db_request.params = MultiDict(params) + + view = views.ManageOIDCPublisherViews(project, db_request) + + assert isinstance( + view.manage_project_oidc_publisher_constrain_environment(), HTTPSeeOther + ) + assert view.metrics.increment.calls == [ + pretend.call( + "warehouse.oidc.constrain_publisher_environment.attempt", + ), + ] + + # The old publisher is should still be present in the DB, because other_project + # still uses it. + assert db_request.db.query(OIDCPublisher).count() == 2 + assert ( + db_request.db.query(GitHubPublisher) + .filter(GitHubPublisher.environment == "") + .filter(GitHubPublisher.projects.contains(other_project)) + .count() + ) == 1 + + # The new constrained publisher should exist, and associated to the current + # project + constrained_publisher = ( + db_request.db.query(GitHubPublisher) + .filter(GitHubPublisher.environment == "fakeenv") + .one() + ) + assert project.oidc_publishers == [constrained_publisher] + + assert project.record_event.calls == [ + pretend.call( + tag=EventTag.Project.OIDCPublisherAdded, + request=db_request, + additional={ + "publisher": constrained_publisher.publisher_name, + "id": str(constrained_publisher.id), + "specifier": str(constrained_publisher), + "url": publisher.publisher_url(), + "submitted_by": db_request.user.username, + }, + ), + pretend.call( + tag=EventTag.Project.OIDCPublisherRemoved, + request=db_request, + additional={ + "publisher": publisher.publisher_name, + "id": str(publisher.id), + "specifier": str(publisher), + "url": publisher.publisher_url(), + "submitted_by": db_request.user.username, + }, + ), + ] + assert db_request.flags.disallow_oidc.calls == [pretend.call()] + assert db_request.session.flash.calls == [ + pretend.call( + f"Trusted publisher for project {project.name!r} has been " + f"constrained to environment 'fakeenv'", + queue="success", + ) + ] + + def test_constrain_oidc_publisher_admin_disabled(self, monkeypatch): + project = pretend.stub() + params = { + "publisher_id": uuid.uuid4(), + "constrain_environment": "fakeenv", + } + request = pretend.stub( + method="GET", + params=MultiDict(params), + user=pretend.stub(), + find_service=lambda *a, **kw: None, + flags=pretend.stub( + disallow_oidc=pretend.call_recorder(lambda f=None: True) + ), + session=pretend.stub(flash=pretend.call_recorder(lambda *a, **kw: None)), + _=lambda s: s, + POST=MultiDict(), + registry=pretend.stub(settings={}), + ) + + view = views.ManageOIDCPublisherViews(project, request) + default_response = {"_": pretend.stub()} + monkeypatch.setattr( + views.ManageOIDCPublisherViews, "default_response", default_response + ) + + assert ( + view.manage_project_oidc_publisher_constrain_environment() + == default_response + ) + assert request.session.flash.calls == [ + pretend.call( + ( + "Trusted publishing is temporarily disabled. See " + "https://pypi.org/help#admin-intervention for details." + ), + queue="error", + ) + ] + + def test_constrain_oidc_publisher_invalid_params(self, monkeypatch, metrics): + project = pretend.stub() + params = { + "publisher_id": "not_an_uuid", + "constrain_environment": "fakeenv", + } + request = pretend.stub( + method="GET", + params=MultiDict(params), + user=pretend.stub(), + find_service=lambda *a, **kw: metrics, + flags=pretend.stub( + disallow_oidc=pretend.call_recorder(lambda f=None: False) + ), + session=pretend.stub(flash=pretend.call_recorder(lambda *a, **kw: None)), + _=lambda s: s, + POST=MultiDict(), + registry=pretend.stub(settings={}), + ) + + view = views.ManageOIDCPublisherViews(project, request) + default_response = {"_": pretend.stub()} + monkeypatch.setattr( + views.ManageOIDCPublisherViews, "default_response", default_response + ) + + assert ( + view.manage_project_oidc_publisher_constrain_environment() + == default_response + ) + assert view.metrics.increment.calls == [ + pretend.call("warehouse.oidc.constrain_publisher_environment.attempt") + ] + assert request.session.flash.calls == [ + pretend.call( + "The trusted publisher could not be constrained", + queue="error", + ) + ] + + def test_constrain_non_extant_oidc_publisher( + self, monkeypatch, metrics, db_request + ): + project = pretend.stub() + params = { + "publisher_id": str(uuid.uuid4()), + "constrain_environment": "fakeenv", + } + db_request.params = MultiDict(params) + db_request.method = "GET" + db_request.POST = MultiDict() + db_request.find_service = lambda *a, **kw: metrics + db_request.session = pretend.stub( + flash=pretend.call_recorder(lambda *a, **kw: None) + ) + db_request.flags = pretend.stub( + disallow_oidc=pretend.call_recorder(lambda f=None: False) + ) + + view = views.ManageOIDCPublisherViews(project, db_request) + default_response = {"_": pretend.stub()} + monkeypatch.setattr( + views.ManageOIDCPublisherViews, "default_response", default_response + ) + + assert ( + view.manage_project_oidc_publisher_constrain_environment() + == default_response + ) + assert view.metrics.increment.calls == [ + pretend.call("warehouse.oidc.constrain_publisher_environment.attempt") + ] + assert db_request.session.flash.calls == [ + pretend.call( + "Invalid publisher for project", + queue="error", + ) + ] + + def test_constrain_publisher_from_different_project( + self, monkeypatch, metrics, db_request + ): + owner = UserFactory.create() + db_request.user = owner + + publisher = GitHubPublisher( + repository_name="some-repository", + repository_owner="some-owner", + repository_owner_id="666", + workflow_filename="some-workflow-filename.yml", + environment="", + ) + + request_project = ProjectFactory.create(oidc_publishers=[]) + request_project.record_event = pretend.call_recorder(lambda *a, **kw: None) + RoleFactory.create(user=owner, project=request_project, role_name="Owner") + + ProjectFactory.create(oidc_publishers=[publisher]) + + db_request.db.add(publisher) + db_request.db.flush() # To get the id + + params = { + "publisher_id": str(publisher.id), + "constrain_environment": "fakeenv", + } + db_request.params = MultiDict(params) + db_request.method = "GET" + db_request.POST = MultiDict() + db_request.find_service = lambda *a, **kw: metrics + db_request.session = pretend.stub( + flash=pretend.call_recorder(lambda *a, **kw: None) + ) + db_request.flags = pretend.stub( + disallow_oidc=pretend.call_recorder(lambda f=None: False) + ) + + view = views.ManageOIDCPublisherViews(request_project, db_request) + default_response = {"_": pretend.stub()} + monkeypatch.setattr( + views.ManageOIDCPublisherViews, "default_response", default_response + ) + + assert ( + view.manage_project_oidc_publisher_constrain_environment() + == default_response + ) + assert view.metrics.increment.calls == [ + pretend.call("warehouse.oidc.constrain_publisher_environment.attempt") + ] + assert db_request.session.flash.calls == [ + pretend.call( + "Invalid publisher for project", + queue="error", + ) + ] + + @pytest.mark.parametrize( + "publisher", + [ + ActiveStatePublisher( + organization="some-org", + activestate_project_name="some-project", + actor="some-user", + actor_id="some-user-id", + ), + GooglePublisher( + email="some-email@example.com", + sub="some-sub", + ), + ], + ) + def test_constrain_unsupported_publisher( + self, monkeypatch, metrics, db_request, publisher + ): + owner = UserFactory.create() + db_request.user = owner + db_request.db.add(publisher) + db_request.db.flush() # To get the id + + project = ProjectFactory.create(oidc_publishers=[publisher]) + project.record_event = pretend.call_recorder(lambda *a, **kw: None) + RoleFactory.create(user=owner, project=project, role_name="Owner") + + params = { + "publisher_id": str(publisher.id), + "constrain_environment": "fakeenv", + } + db_request.params = MultiDict(params) + db_request.method = "GET" + db_request.POST = MultiDict() + db_request.find_service = lambda *a, **kw: metrics + db_request.session = pretend.stub( + flash=pretend.call_recorder(lambda *a, **kw: None) + ) + db_request.flags = pretend.stub( + disallow_oidc=pretend.call_recorder(lambda f=None: False) + ) + + view = views.ManageOIDCPublisherViews(project, db_request) + default_response = {"_": pretend.stub()} + monkeypatch.setattr( + views.ManageOIDCPublisherViews, "default_response", default_response + ) + + assert ( + view.manage_project_oidc_publisher_constrain_environment() + == default_response + ) + assert view.metrics.increment.calls == [ + pretend.call("warehouse.oidc.constrain_publisher_environment.attempt") + ] + assert db_request.session.flash.calls == [ + pretend.call( + "Can only constrain the environment for GitHub and GitLab publishers", + queue="error", + ) + ] + + def test_constrain_publisher_already_constrained( + self, monkeypatch, metrics, db_request + ): + owner = UserFactory.create() + db_request.user = owner + + publisher = GitHubPublisher( + repository_name="some-repository", + repository_owner="some-owner", + repository_owner_id="666", + workflow_filename="some-workflow-filename.yml", + environment="env-already-constrained", + ) + + project = ProjectFactory.create(oidc_publishers=[publisher]) + project.record_event = pretend.call_recorder(lambda *a, **kw: None) + RoleFactory.create(user=owner, project=project, role_name="Owner") + + db_request.db.add(publisher) + db_request.db.flush() # To get the id + + params = { + "publisher_id": str(publisher.id), + "constrain_environment": "fakeenv", + } + db_request.params = MultiDict(params) + db_request.method = "GET" + db_request.POST = MultiDict() + db_request.find_service = lambda *a, **kw: metrics + db_request.session = pretend.stub( + flash=pretend.call_recorder(lambda *a, **kw: None) + ) + db_request.flags = pretend.stub( + disallow_oidc=pretend.call_recorder(lambda f=None: False) + ) + + view = views.ManageOIDCPublisherViews(project, db_request) + default_response = {"_": pretend.stub()} + monkeypatch.setattr( + views.ManageOIDCPublisherViews, "default_response", default_response + ) + + assert ( + view.manage_project_oidc_publisher_constrain_environment() + == default_response + ) + assert view.metrics.increment.calls == [ + pretend.call("warehouse.oidc.constrain_publisher_environment.attempt") + ] + assert db_request.session.flash.calls == [ + pretend.call( + "Can only constrain the environment for publishers without an " + "environment configured", + queue="error", + ) + ] + @pytest.mark.parametrize( ("view_name", "publisher", "make_form"), [ diff --git a/tests/unit/oidc/test_views.py b/tests/unit/oidc/test_views.py index e50d5707ab47..e1e29be21e16 100644 --- a/tests/unit/oidc/test_views.py +++ b/tests/unit/oidc/test_views.py @@ -19,8 +19,10 @@ from tests.common.db.accounts import UserFactory from tests.common.db.oidc import ( + ActiveStatePublisherFactory, GitHubPublisherFactory, GitLabPublisherFactory, + GooglePublisherFactory, PendingGitHubPublisherFactory, ) from tests.common.db.packaging import ProhibitedProjectFactory, ProjectFactory @@ -29,8 +31,11 @@ from warehouse.macaroons.interfaces import IMacaroonService from warehouse.metrics import IMetricsService from warehouse.oidc import errors, views -from warehouse.oidc.interfaces import IOIDCPublisherService -from warehouse.oidc.views import is_from_reusable_workflow +from warehouse.oidc.interfaces import IOIDCPublisherService, SignedClaims +from warehouse.oidc.views import ( + is_from_reusable_workflow, + should_send_environment_warning_email, +) from warehouse.packaging import services from warehouse.rate_limiting.interfaces import IRateLimiter @@ -448,7 +453,7 @@ def test_mint_token_from_oidc_pending_publisher_ok( repository_owner="foo", repository_owner_id="123", workflow_filename="example.yml", - environment="", + environment="fake", ) db_request.flags.disallow_oidc = lambda f=None: False @@ -486,7 +491,7 @@ def test_mint_token_from_pending_trusted_publisher_invalidates_others( repository_owner="foo", repository_owner_id="123", workflow_filename="example.yml", - environment="", + environment="fake", ) # Create some other pending publishers for the same nonexistent project, @@ -636,6 +641,119 @@ def find_service(iface, **kw): ] +def test_mint_token_warn_constrain_environment( + monkeypatch, db_request, dummy_github_oidc_jwt +): + claims_in_token = {"ref": "someref", "sha": "somesha", "environment": "fakeenv"} + claims_input = {"ref": "someref", "sha": "somesha"} + time = pretend.stub(time=pretend.call_recorder(lambda: 0)) + monkeypatch.setattr(views, "time", time) + owner = UserFactory.create() + + project = pretend.stub( + id="fakeprojectid", + name="fakeproject", + record_event=pretend.call_recorder(lambda **kw: None), + owners=[owner], + ) + + publisher = GitHubPublisherFactory(environment="") + monkeypatch.setattr(publisher.__class__, "projects", [project]) + publisher.publisher_url = pretend.call_recorder(lambda **kw: "https://fake/url") + # NOTE: Can't set __str__ using pretend.stub() + monkeypatch.setattr(publisher.__class__, "__str__", lambda s: "fakespecifier") + + send_environment_ignored_in_trusted_publisher_email = pretend.call_recorder( + lambda *a, **kw: None + ) + monkeypatch.setattr( + views, + "send_environment_ignored_in_trusted_publisher_email", + send_environment_ignored_in_trusted_publisher_email, + ) + + def _find_publisher(claims, pending=False): + if pending: + return None + else: + return publisher + + oidc_service = pretend.stub( + verify_jwt_signature=pretend.call_recorder(lambda token: claims_in_token), + find_publisher=pretend.call_recorder(_find_publisher), + ) + + db_macaroon = pretend.stub(description="fakemacaroon") + macaroon_service = pretend.stub( + create_macaroon=pretend.call_recorder( + lambda *a, **kw: ("raw-macaroon", db_macaroon) + ) + ) + + def find_service(iface, **kw): + if iface == IOIDCPublisherService: + return oidc_service + elif iface == IMacaroonService: + return macaroon_service + else: + pytest.fail(iface) + + monkeypatch.setattr(db_request, "find_service", find_service) + monkeypatch.setattr(db_request, "domain", "fakedomain") + + response = views.mint_token(oidc_service, dummy_github_oidc_jwt, db_request) + assert response == { + "success": True, + "token": "raw-macaroon", + } + + assert oidc_service.verify_jwt_signature.calls == [ + pretend.call(dummy_github_oidc_jwt) + ] + assert oidc_service.find_publisher.calls == [ + pretend.call(claims_in_token, pending=True), + pretend.call(claims_in_token, pending=False), + ] + + assert send_environment_ignored_in_trusted_publisher_email.calls == [ + pretend.call( + db_request, + {owner}, + project_name="fakeproject", + publisher=publisher, + environment_name="fakeenv", + ), + ] + + assert macaroon_service.create_macaroon.calls == [ + pretend.call( + "fakedomain", + f"OpenID token: fakespecifier ({datetime.fromtimestamp(0).isoformat()})", + [ + caveats.OIDCPublisher( + oidc_publisher_id=str(publisher.id), + ), + caveats.ProjectID(project_ids=["fakeprojectid"]), + caveats.Expiration(expires_at=900, not_before=0), + ], + oidc_publisher_id=str(publisher.id), + additional={"oidc": claims_input}, + ) + ] + assert project.record_event.calls == [ + pretend.call( + tag=EventTag.Project.ShortLivedAPITokenAdded, + request=db_request, + additional={ + "expires": 900, + "publisher_name": "GitHub", + "publisher_url": "https://fake/url", + "reusable_workflow_used": False, + }, + ) + ] + + def test_mint_token_with_prohibited_name_fails( monkeypatch, db_request, @@ -846,3 +964,42 @@ def test_is_from_reusable_workflow( publisher = GitHubPublisherFactory() if is_github else GitLabPublisherFactory() assert is_from_reusable_workflow(publisher, claims) == is_reusable + + +@pytest.mark.parametrize( + ( + "publisher_factory", + "publisher_environment", + "claims_environment", + "should_send", + ), + [ + # Should send for GitHub/GitLab publishers with no environment + # configured when claims contain an environment + (GitHubPublisherFactory, "", "new_env", True), + (GitLabPublisherFactory, "", "new_env", True), + # Should not send if claims don't have an environent + (GitHubPublisherFactory, "", "", False), + (GitLabPublisherFactory, "", "", False), + # Should not send if publishers already have an environment + (GitHubPublisherFactory, "env", "new_env", False), + (GitLabPublisherFactory, "env", "new_env", False), + # Should not send if publisher is not GitHub/GitLab + (ActiveStatePublisherFactory, None, "new_env", False), + (GooglePublisherFactory, None, "new_env", False), + ], +) +def test_should_send_environment_warning_email( + db_request, + publisher_factory, + publisher_environment, + claims_environment, + should_send, +): + if publisher_environment is None: + publisher = publisher_factory() + else: + publisher = publisher_factory(environment=publisher_environment) + + claims = SignedClaims({"environment": claims_environment}) + assert should_send_environment_warning_email(publisher, claims) == should_send diff --git a/warehouse/email/__init__.py b/warehouse/email/__init__.py index 431f660b7b16..e507ddb3a41a 100644 --- a/warehouse/email/__init__.py +++ b/warehouse/email/__init__.py @@ -1093,6 +1093,17 @@ def send_pep625_version_email( } +@_email("environment-ignored-in-trusted-publisher") +def send_environment_ignored_in_trusted_publisher_email( + request, users, project_name, publisher, environment_name +): + return { + "project_name": project_name, + "publisher": publisher, + "environment_name": environment_name, + } + + def includeme(config): email_sending_class = config.maybe_dotted(config.registry.settings["mail.backend"]) config.register_service_factory(email_sending_class.create_service, IEmailSender) diff --git a/warehouse/locale/messages.pot b/warehouse/locale/messages.pot index 1e2004814d4a..af5b781d2bdb 100644 --- a/warehouse/locale/messages.pot +++ b/warehouse/locale/messages.pot @@ -152,7 +152,7 @@ msgstr "" msgid "Successful WebAuthn assertion" msgstr "" -#: warehouse/accounts/views.py:609 warehouse/manage/views/__init__.py:873 +#: warehouse/accounts/views.py:609 warehouse/manage/views/__init__.py:874 msgid "Recovery code accepted. The supplied code cannot be used again." msgstr "" @@ -286,7 +286,7 @@ msgid "You are now ${role} of the '${project_name}' project." msgstr "" #: warehouse/accounts/views.py:1588 warehouse/accounts/views.py:1831 -#: warehouse/manage/views/__init__.py:1409 +#: warehouse/manage/views/__init__.py:1410 msgid "" "Trusted publishing is temporarily disabled. See https://pypi.org/help" "#admin-intervention for details." @@ -306,19 +306,19 @@ msgstr "" msgid "You can't register more than 3 pending trusted publishers at once." msgstr "" -#: warehouse/accounts/views.py:1654 warehouse/manage/views/__init__.py:1464 -#: warehouse/manage/views/__init__.py:1577 +#: warehouse/accounts/views.py:1654 warehouse/manage/views/__init__.py:1576 #: warehouse/manage/views/__init__.py:1689 -#: warehouse/manage/views/__init__.py:1799 +#: warehouse/manage/views/__init__.py:1801 +#: warehouse/manage/views/__init__.py:1911 msgid "" "There have been too many attempted trusted publisher registrations. Try " "again later." msgstr "" -#: warehouse/accounts/views.py:1665 warehouse/manage/views/__init__.py:1478 -#: warehouse/manage/views/__init__.py:1591 +#: warehouse/accounts/views.py:1665 warehouse/manage/views/__init__.py:1590 #: warehouse/manage/views/__init__.py:1703 -#: warehouse/manage/views/__init__.py:1813 +#: warehouse/manage/views/__init__.py:1815 +#: warehouse/manage/views/__init__.py:1925 msgid "The trusted publisher could not be registered" msgstr "" @@ -446,157 +446,161 @@ msgid "" "less." msgstr "" -#: warehouse/manage/views/__init__.py:285 +#: warehouse/manage/views/__init__.py:286 msgid "Account details updated" msgstr "" -#: warehouse/manage/views/__init__.py:315 +#: warehouse/manage/views/__init__.py:316 msgid "Email ${email_address} added - check your email for a verification link" msgstr "" -#: warehouse/manage/views/__init__.py:821 +#: warehouse/manage/views/__init__.py:822 msgid "Recovery codes already generated" msgstr "" -#: warehouse/manage/views/__init__.py:822 +#: warehouse/manage/views/__init__.py:823 msgid "Generating new recovery codes will invalidate your existing codes." msgstr "" -#: warehouse/manage/views/__init__.py:931 +#: warehouse/manage/views/__init__.py:932 msgid "Verify your email to create an API token." msgstr "" -#: warehouse/manage/views/__init__.py:1031 +#: warehouse/manage/views/__init__.py:1032 msgid "API Token does not exist." msgstr "" -#: warehouse/manage/views/__init__.py:1063 +#: warehouse/manage/views/__init__.py:1064 msgid "Invalid credentials. Try again" msgstr "" -#: warehouse/manage/views/__init__.py:1182 +#: warehouse/manage/views/__init__.py:1183 msgid "Invalid alternate repository location details" msgstr "" -#: warehouse/manage/views/__init__.py:1219 +#: warehouse/manage/views/__init__.py:1220 msgid "Added alternate repository '${name}'" msgstr "" -#: warehouse/manage/views/__init__.py:1253 -#: warehouse/manage/views/__init__.py:2146 -#: warehouse/manage/views/__init__.py:2231 -#: warehouse/manage/views/__init__.py:2332 -#: warehouse/manage/views/__init__.py:2432 +#: warehouse/manage/views/__init__.py:1254 +#: warehouse/manage/views/__init__.py:2258 +#: warehouse/manage/views/__init__.py:2343 +#: warehouse/manage/views/__init__.py:2444 +#: warehouse/manage/views/__init__.py:2544 msgid "Confirm the request" msgstr "" -#: warehouse/manage/views/__init__.py:1265 +#: warehouse/manage/views/__init__.py:1266 msgid "Invalid alternate repository id" msgstr "" -#: warehouse/manage/views/__init__.py:1276 +#: warehouse/manage/views/__init__.py:1277 msgid "Invalid alternate repository for project" msgstr "" -#: warehouse/manage/views/__init__.py:1284 +#: warehouse/manage/views/__init__.py:1285 msgid "" "Could not delete alternate repository - ${confirm} is not the same as " "${alt_repo_name}" msgstr "" -#: warehouse/manage/views/__init__.py:1314 +#: warehouse/manage/views/__init__.py:1315 msgid "Deleted alternate repository '${name}'" msgstr "" -#: warehouse/manage/views/__init__.py:1445 +#: warehouse/manage/views/__init__.py:1459 +msgid "The trusted publisher could not be constrained" +msgstr "" + +#: warehouse/manage/views/__init__.py:1557 msgid "" "GitHub-based trusted publishing is temporarily disabled. See " "https://pypi.org/help#admin-intervention for details." msgstr "" -#: warehouse/manage/views/__init__.py:1558 +#: warehouse/manage/views/__init__.py:1670 msgid "" "GitLab-based trusted publishing is temporarily disabled. See " "https://pypi.org/help#admin-intervention for details." msgstr "" -#: warehouse/manage/views/__init__.py:1670 +#: warehouse/manage/views/__init__.py:1782 msgid "" "Google-based trusted publishing is temporarily disabled. See " "https://pypi.org/help#admin-intervention for details." msgstr "" -#: warehouse/manage/views/__init__.py:1779 +#: warehouse/manage/views/__init__.py:1891 msgid "" "ActiveState-based trusted publishing is temporarily disabled. See " "https://pypi.org/help#admin-intervention for details." msgstr "" -#: warehouse/manage/views/__init__.py:2014 -#: warehouse/manage/views/__init__.py:2315 -#: warehouse/manage/views/__init__.py:2423 +#: warehouse/manage/views/__init__.py:2126 +#: warehouse/manage/views/__init__.py:2427 +#: warehouse/manage/views/__init__.py:2535 msgid "" "Project deletion temporarily disabled. See https://pypi.org/help#admin-" "intervention for details." msgstr "" -#: warehouse/manage/views/__init__.py:2158 +#: warehouse/manage/views/__init__.py:2270 msgid "Could not yank release - " msgstr "" -#: warehouse/manage/views/__init__.py:2243 +#: warehouse/manage/views/__init__.py:2355 msgid "Could not un-yank release - " msgstr "" -#: warehouse/manage/views/__init__.py:2344 +#: warehouse/manage/views/__init__.py:2456 msgid "Could not delete release - " msgstr "" -#: warehouse/manage/views/__init__.py:2444 +#: warehouse/manage/views/__init__.py:2556 msgid "Could not find file" msgstr "" -#: warehouse/manage/views/__init__.py:2448 +#: warehouse/manage/views/__init__.py:2560 msgid "Could not delete file - " msgstr "" -#: warehouse/manage/views/__init__.py:2598 +#: warehouse/manage/views/__init__.py:2710 msgid "Team '${team_name}' already has ${role_name} role for project" msgstr "" -#: warehouse/manage/views/__init__.py:2705 +#: warehouse/manage/views/__init__.py:2817 msgid "User '${username}' already has ${role_name} role for project" msgstr "" -#: warehouse/manage/views/__init__.py:2772 +#: warehouse/manage/views/__init__.py:2884 msgid "${username} is now ${role} of the '${project_name}' project." msgstr "" -#: warehouse/manage/views/__init__.py:2804 +#: warehouse/manage/views/__init__.py:2916 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:2817 +#: warehouse/manage/views/__init__.py:2929 #: warehouse/manage/views/organizations.py:878 msgid "User '${username}' already has an active invite. Please try again later." msgstr "" -#: warehouse/manage/views/__init__.py:2882 +#: warehouse/manage/views/__init__.py:2994 #: warehouse/manage/views/organizations.py:943 msgid "Invitation sent to '${username}'" msgstr "" -#: warehouse/manage/views/__init__.py:2915 +#: warehouse/manage/views/__init__.py:3027 msgid "Could not find role invitation." msgstr "" -#: warehouse/manage/views/__init__.py:2926 +#: warehouse/manage/views/__init__.py:3038 msgid "Invitation already expired." msgstr "" -#: warehouse/manage/views/__init__.py:2958 +#: warehouse/manage/views/__init__.py:3070 #: warehouse/manage/views/organizations.py:1130 msgid "Invitation revoked from '${username}'." msgstr "" @@ -649,14 +653,18 @@ msgid "" "library module name)" msgstr "" -#: warehouse/oidc/forms/_core.py:84 +#: warehouse/oidc/forms/_core.py:84 warehouse/oidc/forms/_core.py:95 msgid "Specify a publisher ID" msgstr "" -#: warehouse/oidc/forms/_core.py:85 +#: warehouse/oidc/forms/_core.py:85 warehouse/oidc/forms/_core.py:96 msgid "Publisher must be specified by ID" msgstr "" +#: warehouse/oidc/forms/_core.py:101 +msgid "Specify an environment name" +msgstr "" + #: warehouse/oidc/forms/activestate.py:47 msgid "Double dashes are not allowed in the name" msgstr "" @@ -2073,6 +2081,47 @@ msgid "" "organization" msgstr "" +#: warehouse/templates/email/environment-ignored-in-trusted-publisher/body.html:20 +#, python-format +msgid "" +"A Trusted Publisher for project %(project_name)s was just used from a CI/CD" +" job configured with a %(publisher_name)s environment. The environment " +"used was: %(environment_name)s." +msgstr "" + +#: warehouse/templates/email/environment-ignored-in-trusted-publisher/body.html:28 +msgid "" +"

Since the Trusted Publisher is configured to allow " +"any environment, for security reasons we recommend " +"constraining it to only one.

" +msgstr "" + +#: warehouse/templates/email/environment-ignored-in-trusted-publisher/body.html:35 +#, python-format +msgid "" +"If you are an owner of this project, you can automatically constrain this" +" Trusted Publisher to '%(environment_name)s' by following this link: constrain publisher." +msgstr "" + +#: warehouse/templates/email/environment-ignored-in-trusted-publisher/body.html:44 +#, python-format +msgid "" +"Alternatively, you can do this manually by going to the project's publishing settings, deleting the existing " +"Trusted Publisher and creating a new one with the environment set to " +"'%(environment_name)s'." +msgstr "" + +#: warehouse/templates/email/environment-ignored-in-trusted-publisher/body.html:53 +#, python-format +msgid "" +"If you have questions, you can email %(email_address)s to communicate with the PyPI " +"administrators." +msgstr "" + #: warehouse/templates/email/new-email-added/body.html:17 #, python-format msgid "" diff --git a/warehouse/manage/views/__init__.py b/warehouse/manage/views/__init__.py index a5f9602ff5be..9618485b5049 100644 --- a/warehouse/manage/views/__init__.py +++ b/warehouse/manage/views/__init__.py @@ -110,6 +110,7 @@ GitLabPublisherForm, GooglePublisherForm, ) +from warehouse.oidc.forms._core import ConstrainEnvirormentForm from warehouse.oidc.interfaces import TooManyOIDCRegistrations from warehouse.oidc.models import ( ActiveStatePublisher, @@ -1435,6 +1436,117 @@ def manage_project_oidc_publishers_prefill(self): return self.manage_project_oidc_publishers() + @view_config( + request_method="GET", request_param=ConstrainEnvirormentForm.__params__ + ) + def manage_project_oidc_publisher_constrain_environment(self): + if self.request.flags.disallow_oidc(): + self.request.session.flash( + ( + "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.constrain_publisher_environment.attempt") + + form = ConstrainEnvirormentForm(self.request.params) + + if not form.validate(): + self.request.session.flash( + self.request._("The trusted publisher could not be constrained"), + queue="error", + ) + return self.default_response + + publisher = self.request.db.get(OIDCPublisher, form.publisher_id.data) + + if publisher is None or publisher not in self.project.oidc_publishers: + self.request.session.flash( + "Invalid publisher for project", + queue="error", + ) + return self.default_response + + # First we add the new trusted publisher + if isinstance(publisher, GitHubPublisher): + constrained_publisher = GitHubPublisher( + repository_name=publisher.repository_name, + repository_owner=publisher.repository_owner, + repository_owner_id=publisher.repository_owner_id, + workflow_filename=publisher.workflow_filename, + environment=form.constrain_environment.data, + ) + elif isinstance(publisher, GitLabPublisher): + constrained_publisher = GitLabPublisher( + namespace=publisher.namespace, + project=publisher.project, + workflow_filepath=publisher.workflow_filepath, + environment=form.constrain_environment.data, + ) + + else: + self.request.session.flash( + "Can only constrain the environment for GitHub and GitLab publishers", + queue="error", + ) + return self.default_response + + if publisher.environment != "": + self.request.session.flash( + "Can only constrain the environment for publishers without an " + "environment configured", + queue="error", + ) + return self.default_response + + self.request.db.add(constrained_publisher) + self.request.db.flush() # ensure constrained_publisher.id is available + self.project.oidc_publishers.append(constrained_publisher) + + self.project.record_event( + tag=EventTag.Project.OIDCPublisherAdded, + request=self.request, + additional={ + "publisher": constrained_publisher.publisher_name, + "id": str(constrained_publisher.id), + "specifier": str(constrained_publisher), + "url": constrained_publisher.publisher_url(), + "submitted_by": self.request.user.username, + }, + ) + + # Then, we remove the old trusted publisher from the project + # and, if there are no projects left associated with the publisher, + # we delete it entirely. + self.project.oidc_publishers.remove(publisher) + if len(publisher.projects) == 0: + self.request.db.delete(publisher) + + self.project.record_event( + tag=EventTag.Project.OIDCPublisherRemoved, + 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( + self.request._( + f"Trusted publisher for project {self.project.name!r} has been " + f"constrained to environment {constrained_publisher.environment!r}" + ), + queue="success", + ) + + return HTTPSeeOther(self.request.path) + @view_config( request_method="POST", request_param=GitHubPublisherForm.__params__, diff --git a/warehouse/oidc/forms/_core.py b/warehouse/oidc/forms/_core.py index fb2af35d0001..8f4a1e437e92 100644 --- a/warehouse/oidc/forms/_core.py +++ b/warehouse/oidc/forms/_core.py @@ -85,3 +85,19 @@ class DeletePublisherForm(wtforms.Form): wtforms.validators.UUID(message=_("Publisher must be specified by ID")), ] ) + + +class ConstrainEnvirormentForm(wtforms.Form): + __params__ = ["publisher_id", "constrain_environment"] + + publisher_id = wtforms.StringField( + validators=[ + wtforms.validators.InputRequired(message=_("Specify a publisher ID")), + wtforms.validators.UUID(message=_("Publisher must be specified by ID")), + ] + ) + constrain_environment = wtforms.StringField( + validators=[ + wtforms.validators.InputRequired(message=_("Specify an environment name")), + ] + ) diff --git a/warehouse/oidc/views.py b/warehouse/oidc/views.py index c3d202b24360..02f35fc099ac 100644 --- a/warehouse/oidc/views.py +++ b/warehouse/oidc/views.py @@ -23,6 +23,7 @@ from pyramid.request import Request from pyramid.view import view_config +from warehouse.email import send_environment_ignored_in_trusted_publisher_email from warehouse.events.tags import EventTag from warehouse.macaroons import caveats from warehouse.macaroons.interfaces import IMacaroonService @@ -31,6 +32,7 @@ from warehouse.oidc.errors import InvalidPublisherError, ReusedTokenError from warehouse.oidc.interfaces import IOIDCPublisherService, SignedClaims from warehouse.oidc.models import GitHubPublisher, OIDCPublisher, PendingOIDCPublisher +from warehouse.oidc.models.gitlab import GitLabPublisher from warehouse.oidc.services import OIDCPublisherService from warehouse.oidc.utils import OIDC_ISSUER_ADMIN_FLAGS, OIDC_ISSUER_SERVICE_NAMES from warehouse.packaging.interfaces import IProjectService @@ -96,12 +98,14 @@ def oidc_audience(request: Request): require_methods=["POST"], renderer="json", require_csrf=False, + has_translations=True, ) @view_config( route_name="oidc.mint_token", require_methods=["POST"], renderer="json", require_csrf=False, + has_translations=True, ) def mint_token_from_oidc(request: Request): try: @@ -304,6 +308,25 @@ def mint_token( }, ) + # Send a warning email to the owners of the project using the Trusted Publisher if + # the TP has no environment configured but the OIDC claims contain one. + # The email contains a link to change the TP so that it only accepts the + # environment seen in the current OIDC claims. + # + # Note: currently we only send the email if the Trusted Publisher is used in only + # a single project, since multiple projects using the same TP might mean they don't + # use a single environment. + if len(publisher.projects) == 1 and should_send_environment_warning_email( + publisher, claims + ): + send_environment_ignored_in_trusted_publisher_email( + request, + set(publisher.projects[0].owners), + project_name=publisher.projects[0].name, + publisher=publisher, + environment_name=claims["environment"], + ) + # NOTE: This is for temporary metrics collection of GitHub Trusted Publishers # that use reusable workflows. Since support for reusable workflows is accidental # and not correctly implemented, we need to understand how widely it's being @@ -331,3 +354,23 @@ def is_from_reusable_workflow( # With non-reusable workflows they are the same, so we count reusable # workflows by checking if they are different. return bool(job_workflow_ref and workflow_ref and job_workflow_ref != workflow_ref) + + +def should_send_environment_warning_email( + publisher: OIDCPublisher, claims: SignedClaims +) -> bool: + """ + Determine if the claims contain an environment but the publisher doesn't + + If the publisher does not have an environment configured but the claims + contain one, it means the project can easily improve security by constraining + the Trusted Publisher to only that environment. + + This currently only applies to GitHub and GitLab publishers. + """ + if not isinstance(publisher, (GitHubPublisher, GitLabPublisher)): + return False + + claims_env = claims.get("environment") + + return publisher.environment == "" and claims_env is not None and claims_env != "" diff --git a/warehouse/templates/email/environment-ignored-in-trusted-publisher/body.html b/warehouse/templates/email/environment-ignored-in-trusted-publisher/body.html new file mode 100644 index 000000000000..be55f5e077cd --- /dev/null +++ b/warehouse/templates/email/environment-ignored-in-trusted-publisher/body.html @@ -0,0 +1,59 @@ +{# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +-#} +{% extends "email/_base/body.html" %} + +{% set domain = request.registry.settings.get('warehouse.domain') %} + +{% block content %} +

+ {% trans publisher_name=publisher.publisher_name, project_name=project_name, environment_name=environment_name, + project_href=request.route_url('packaging.project', name=project_name, _host=domain) + %} + A Trusted Publisher for project {{ project_name }} was just + used from a CI/CD job configured with a {{ publisher_name }} environment. + The environment used was: {{ environment_name }}. + {% endtrans %} +

+ {% trans %} +

+ Since the Trusted Publisher is configured to allow any environment, + for security reasons we recommend constraining it to only one. +

+ {% endtrans %} +

+ {% trans environment_name=environment_name, + auto_href=request.route_url('manage.project.settings.publishing', project_name=project_name, _host=domain, + _query={'publisher_id': publisher.id, 'constrain_environment': environment_name }) + %} + If you are an owner of this project, you can automatically constrain this Trusted Publisher to + '{{ environment_name }}' by following this link: constrain publisher. + {% endtrans %} +

+

+ {% trans environment_name=environment_name, + manual_href=request.route_url('manage.project.settings.publishing', project_name=project_name, _host=domain) + %} + Alternatively, you can do this manually by going to the project's + publishing settings, deleting + the existing Trusted Publisher and creating a new one with the environment set to '{{ environment_name }}'. + {% endtrans %} +

+

+ {% trans href='mailto:admin@pypi.org', email_address='admin@pypi.org' %} + If you have questions, you can email + {{ email_address }} to communicate with the PyPI + administrators. + {% endtrans %} +

+{% endblock %} diff --git a/warehouse/templates/email/environment-ignored-in-trusted-publisher/body.txt b/warehouse/templates/email/environment-ignored-in-trusted-publisher/body.txt new file mode 100644 index 000000000000..ee2550007c25 --- /dev/null +++ b/warehouse/templates/email/environment-ignored-in-trusted-publisher/body.txt @@ -0,0 +1,58 @@ +{# + # Licensed under the Apache License, Version 2.0 (the "License"); + # you may not use this file except in compliance with the License. + # You may obtain a copy of the License at + # + # http://www.apache.org/licenses/LICENSE-2.0 + # + # Unless required by applicable law or agreed to in writing, software + # distributed under the License is distributed on an "AS IS" BASIS, + # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + # See the License for the specific language governing permissions and + # limitations under the License. +-#} +{% extends "email/_base/body.txt" %} + +{% set domain = request.registry.settings.get('warehouse.domain') %} + +{% block content %} + {% trans publisher_name=publisher.publisher_name, project_name=project_name, environment_name=environment_name, + project_href=request.route_url('packaging.project', name=project_name, _host=domain) + %} + A Trusted Publisher for project {{ project_name }} ({{ project_href }}) was just + used from a CI/CD job configured with a {{ publisher_name }} environment. + The environment used was: {{ environment_name }}. + {% endtrans %} + + + {% trans %} + Since the Trusted is configured to allow any environment, + for security reasons we recommend constraining it to only one. + {% endtrans %} + + + {% trans environment_name=environment_name, + auto_href=request.route_url('manage.project.settings.publishing', project_name=project_name, _host=domain, + _query={'publisher_id': publisher.id, 'constrain_environment': environment_name }) + %} + If you are an owner of this project, you can automatically constrain this Trusted Publisher to + '{{ environment_name }}' by following this link: {{ auto_href }}. + {% endtrans %} + + + {% trans environment_name=environment_name, + manual_href=request.route_url('manage.project.settings.publishing', project_name=project_name, _host=domain) + %} + Alternatively, you can do this manually by going to the project's + publishing settings ({{ manual_href }}), deleting + the existing Trusted Publisher and creating a new one with the environment set to '{{ environment_name }}'. + {% endtrans %} + + + {% trans email_address='admin@pypi.org' %} + If you have questions, you can email + {{ email_address }} to communicate with the PyPI administrators. + {% endtrans %} + +{% endblock %} + diff --git a/warehouse/templates/email/environment-ignored-in-trusted-publisher/subject.txt b/warehouse/templates/email/environment-ignored-in-trusted-publisher/subject.txt new file mode 100644 index 000000000000..8b5c28415d33 --- /dev/null +++ b/warehouse/templates/email/environment-ignored-in-trusted-publisher/subject.txt @@ -0,0 +1,17 @@ +{# + # Licensed under the Apache License, Version 2.0 (the "License"); + # you may not use this file except in compliance with the License. + # You may obtain a copy of the License at + # + # http://www.apache.org/licenses/LICENSE-2.0 + # + # Unless required by applicable law or agreed to in writing, software + # distributed under the License is distributed on an "AS IS" BASIS, + # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + # See the License for the specific language governing permissions and + # limitations under the License. +-#} + +{% extends "email/_base/subject.txt" %} + +{% block subject %}{% trans project_name=project_name %}Trusted Publisher for project {{ project_name }} can be made more secure{% endtrans %}{% endblock %}