From 0d64f527e689c51d35a0f7b38449cf4001d7bf82 Mon Sep 17 00:00:00 2001 From: Mike Fiedler Date: Tue, 4 Nov 2025 18:36:06 -0500 Subject: [PATCH 1/6] feat: connect pending publishers to organizations First step to allowing an Organization to create a Pending Trusted Publisher is to create a link between the models, so that when reify-ing to a real publisher, the ownership relationship can be maintained. Signed-off-by: Mike Fiedler --- ...b_add_org_id_to_pending_oidc_publishers.py | 40 +++++++++++++++++++ warehouse/oidc/models/_core.py | 10 +++++ warehouse/organizations/models.py | 4 ++ 3 files changed, 54 insertions(+) create mode 100644 warehouse/migrations/versions/6c0f7fea7b1b_add_org_id_to_pending_oidc_publishers.py diff --git a/warehouse/migrations/versions/6c0f7fea7b1b_add_org_id_to_pending_oidc_publishers.py b/warehouse/migrations/versions/6c0f7fea7b1b_add_org_id_to_pending_oidc_publishers.py new file mode 100644 index 000000000000..36088a3ed448 --- /dev/null +++ b/warehouse/migrations/versions/6c0f7fea7b1b_add_org_id_to_pending_oidc_publishers.py @@ -0,0 +1,40 @@ +# SPDX-License-Identifier: Apache-2.0 +""" +Add Org ID to pending_oidc_publishers + +Revision ID: 6c0f7fea7b1b +Revises: daf71d83673f +Create Date: 2025-11-04 23:29:08.395688 +""" + +import sqlalchemy as sa + +from alembic import op + +revision = "6c0f7fea7b1b" +down_revision = "daf71d83673f" + + +def upgrade(): + op.add_column( + "pending_oidc_publishers", + sa.Column("organization_id", sa.UUID(), nullable=True), + ) + op.create_index( + op.f("ix_pending_oidc_publishers_organization_id"), + "pending_oidc_publishers", + ["organization_id"], + unique=False, + ) + op.create_foreign_key( + None, "pending_oidc_publishers", "organizations", ["organization_id"], ["id"] + ) + + +def downgrade(): + op.drop_constraint(None, "pending_oidc_publishers", type_="foreignkey") + op.drop_index( + op.f("ix_pending_oidc_publishers_organization_id"), + table_name="pending_oidc_publishers", + ) + op.drop_column("pending_oidc_publishers", "organization_id") diff --git a/warehouse/oidc/models/_core.py b/warehouse/oidc/models/_core.py index de7472f3da70..37713c8dc5d3 100644 --- a/warehouse/oidc/models/_core.py +++ b/warehouse/oidc/models/_core.py @@ -25,6 +25,7 @@ from warehouse.accounts.models import User from warehouse.macaroons.models import Macaroon from warehouse.oidc.services import OIDCPublisherService + from warehouse.organizations.models import Organization from warehouse.packaging.models import Project @@ -387,6 +388,15 @@ class PendingOIDCPublisher(OIDCPublisherMixin, db.Model): PG_UUID(as_uuid=True), ForeignKey("users.id"), nullable=False, index=True ) added_by: Mapped[User] = orm.relationship(back_populates="pending_oidc_publishers") + organization_id: Mapped[UUID | None] = mapped_column( + PG_UUID(as_uuid=True), + ForeignKey("organizations.id"), + nullable=True, + index=True, + ) + pypi_organization: Mapped[Organization | None] = orm.relationship( + back_populates="pending_oidc_publishers" + ) __table_args__ = ( Index( diff --git a/warehouse/organizations/models.py b/warehouse/organizations/models.py index 7796b789be5b..a3403d14291a 100644 --- a/warehouse/organizations/models.py +++ b/warehouse/organizations/models.py @@ -44,6 +44,7 @@ if typing.TYPE_CHECKING: from pyramid.request import Request + from warehouse.oidc.models import PendingOIDCPublisher from warehouse.packaging.models import Project from warehouse.subscriptions.models import StripeCustomer, StripeSubscription @@ -402,6 +403,9 @@ class Organization(OrganizationMixin, HasEvents, db.Model): oidc_issuers: Mapped[list[OrganizationOIDCIssuer]] = relationship( back_populates="organization", ) + pending_oidc_publishers: Mapped[list[PendingOIDCPublisher]] = relationship( + back_populates="pypi_organization", + ) @property def owners(self): From 27569cccf6414923946066b8e26ac95da4f48447 Mon Sep 17 00:00:00 2001 From: Mike Fiedler Date: Wed, 5 Nov 2025 08:59:15 -0500 Subject: [PATCH 2/6] feat: add manage org publishing route Signed-off-by: Mike Fiedler --- tests/unit/test_routes.py | 7 +++++++ warehouse/routes.py | 7 +++++++ 2 files changed, 14 insertions(+) diff --git a/tests/unit/test_routes.py b/tests/unit/test_routes.py index 480fe8f2660e..61c171a8798a 100644 --- a/tests/unit/test_routes.py +++ b/tests/unit/test_routes.py @@ -312,6 +312,13 @@ def add_redirect_rule(*args, **kwargs): traverse="/{organization_name}", domain=warehouse, ), + pretend.call( + "manage.organization.publishing", + "/manage/organization/{organization_name}/publishing/", + factory="warehouse.organizations.models:OrganizationFactory", + traverse="/{organization_name}", + domain=warehouse, + ), pretend.call( "manage.organization.roles", "/manage/organization/{organization_name}/people/", diff --git a/warehouse/routes.py b/warehouse/routes.py index b4e79bc27dee..554587cba226 100644 --- a/warehouse/routes.py +++ b/warehouse/routes.py @@ -321,6 +321,13 @@ def includeme(config): traverse="/{organization_name}", domain=warehouse, ) + config.add_route( + "manage.organization.publishing", + "/manage/organization/{organization_name}/publishing/", + factory="warehouse.organizations.models:OrganizationFactory", + traverse="/{organization_name}", + domain=warehouse, + ) config.add_route( "manage.organization.roles", "/manage/organization/{organization_name}/people/", From d9bce6a2da57570dbeeec1f1d39b0764402d27fa Mon Sep 17 00:00:00 2001 From: Mike Fiedler Date: Wed, 5 Nov 2025 14:41:09 -0500 Subject: [PATCH 3/6] feat: add manage org publishing views Signed-off-by: Mike Fiedler --- .../manage/test_organization_publishing.py | 325 ++++++++++++ tests/unit/manage/views/test_organizations.py | 347 +++++++++++++ warehouse/manage/views/organizations.py | 309 +++++++++++ .../manage/manage-organization-menu.html | 8 + .../manage/organization/publishing.html | 483 ++++++++++++++++++ 5 files changed, 1472 insertions(+) create mode 100644 tests/functional/manage/test_organization_publishing.py create mode 100644 warehouse/templates/manage/organization/publishing.html diff --git a/tests/functional/manage/test_organization_publishing.py b/tests/functional/manage/test_organization_publishing.py new file mode 100644 index 000000000000..7cc403d1140a --- /dev/null +++ b/tests/functional/manage/test_organization_publishing.py @@ -0,0 +1,325 @@ +# SPDX-License-Identifier: Apache-2.0 + +import time + +from http import HTTPStatus + +import pytest +import responses + +from tests.common.db.accounts import UserFactory +from tests.common.db.organizations import OrganizationFactory, OrganizationRoleFactory +from warehouse.organizations.models import OrganizationRoleType +from warehouse.utils.otp import _get_totp + + +@pytest.mark.usefixtures("_enable_all_oidc_providers") +class TestManageOrganizationPublishing: + @responses.activate + def test_add_pending_github_publisher_to_organization(self, webtest): + """ + An authenticated user who is an organization owner can add a pending + GitHub trusted publisher to their organization. + """ + # Arrange: Create a user with an organization + user = UserFactory.create( + with_verified_primary_email=True, + with_terms_of_service_agreement=True, + clear_pwd="password", + ) + organization = OrganizationFactory.create(name="test-organization") + OrganizationRoleFactory.create( + user=user, + organization=organization, + role_name=OrganizationRoleType.Owner, + ) + + # Mock GitHub API for owner validation + responses.add( + responses.GET, + "https://api.github.com/users/test-owner", + json={ + "id": 123456, + "login": "test-owner", + }, + status=200, + ) + + # Act: Log in + login_page = webtest.get("/account/login/", status=HTTPStatus.OK) + login_form = login_page.forms["login-form"] + csrf_token = login_form["csrf_token"].value + login_form["username"] = user.username + login_form["password"] = "password" + + # Handle 2FA + two_factor_page = login_form.submit().follow(status=HTTPStatus.OK) + two_factor_form = two_factor_page.forms["totp-auth-form"] + two_factor_form["csrf_token"] = csrf_token + two_factor_form["totp_value"] = ( + _get_totp(user.totp_secret).generate(time.time()).decode() + ) + two_factor_form.submit().follow(status=HTTPStatus.OK) + + # Navigate to organization publishing page + publishing_page = webtest.get( + f"/manage/organization/{organization.normalized_name}/publishing/", + status=HTTPStatus.OK, + ) + + # Get logged-in CSRF token + logged_in_csrf_token = publishing_page.html.find( + "input", {"name": "csrf_token"} + )["value"] + + # Fill out the GitHub pending publisher form + github_form = publishing_page.forms["pending-github-publisher-form"] + github_form["csrf_token"] = logged_in_csrf_token + github_form["project_name"] = "test-org-project" + github_form["owner"] = "test-owner" + github_form["repository"] = "test-repo" + github_form["workflow_filename"] = "release.yml" + github_form["environment"] = "" # Optional field + + # Submit the form, redirects back to the same page on success + response = github_form.submit(status=HTTPStatus.SEE_OTHER) + response.follow(status=HTTPStatus.OK) + + # Assert: Verify success + # Check flash messages via the JavaScript endpoint + flash_messages = webtest.get( + "/_includes/unauthed/flash-messages/", status=HTTPStatus.OK + ) + success_message = flash_messages.html.find( + "span", {"class": "notification-bar__message"} + ) + assert success_message is not None + assert "Registered a new pending publisher" in success_message.text + assert "test-org-project" in success_message.text + assert organization.name in success_message.text + + def test_add_pending_gitlab_publisher_to_organization(self, webtest): + """ + An authenticated user who is an organization owner can add a pending + GitLab trusted publisher to their organization. + """ + # Arrange: Create a user with an organization + user = UserFactory.create( + with_verified_primary_email=True, + with_terms_of_service_agreement=True, + clear_pwd="password", + ) + organization = OrganizationFactory.create(name="test-organization") + OrganizationRoleFactory.create( + user=user, + organization=organization, + role_name=OrganizationRoleType.Owner, + ) + + # Act: Log in + login_page = webtest.get("/account/login/", status=HTTPStatus.OK) + login_form = login_page.forms["login-form"] + csrf_token = login_form["csrf_token"].value + login_form["username"] = user.username + login_form["password"] = "password" + + # Handle 2FA + two_factor_page = login_form.submit().follow(status=HTTPStatus.OK) + two_factor_form = two_factor_page.forms["totp-auth-form"] + two_factor_form["csrf_token"] = csrf_token + two_factor_form["totp_value"] = ( + _get_totp(user.totp_secret).generate(time.time()).decode() + ) + two_factor_form.submit().follow(status=HTTPStatus.OK) + + # Navigate to organization publishing page + publishing_page = webtest.get( + f"/manage/organization/{organization.normalized_name}/publishing/", + status=HTTPStatus.OK, + ) + + # Get logged-in CSRF token + logged_in_csrf_token = publishing_page.html.find( + "input", {"name": "csrf_token"} + )["value"] + + # Fill out the GitLab pending publisher form + gitlab_form = publishing_page.forms["pending-gitlab-publisher-form"] + gitlab_form["csrf_token"] = logged_in_csrf_token + gitlab_form["project_name"] = "test-org-gitlab-project" + gitlab_form["namespace"] = "test-namespace" + gitlab_form["project"] = "test-project" + gitlab_form["workflow_filepath"] = ".gitlab-ci.yml" + gitlab_form["environment"] = "" # Optional field + # issuer_url is a hidden field with default value + + # Submit the form, redirects back to the same page on success + response = gitlab_form.submit(status=HTTPStatus.SEE_OTHER) + response.follow(status=HTTPStatus.OK) + + # Assert: Verify success + flash_messages = webtest.get( + "/_includes/unauthed/flash-messages/", status=HTTPStatus.OK + ) + success_message = flash_messages.html.find( + "span", {"class": "notification-bar__message"} + ) + assert success_message is not None + assert "Registered a new pending publisher" in success_message.text + assert "test-org-gitlab-project" in success_message.text + assert organization.name in success_message.text + + def test_add_pending_google_publisher_to_organization(self, webtest): + """ + An authenticated user who is an organization owner can add a pending + Google trusted publisher to their organization. + """ + # Arrange: Create a user with an organization + user = UserFactory.create( + with_verified_primary_email=True, + with_terms_of_service_agreement=True, + clear_pwd="password", + ) + organization = OrganizationFactory.create(name="test-organization") + OrganizationRoleFactory.create( + user=user, + organization=organization, + role_name=OrganizationRoleType.Owner, + ) + + # Act: Log in + login_page = webtest.get("/account/login/", status=HTTPStatus.OK) + login_form = login_page.forms["login-form"] + csrf_token = login_form["csrf_token"].value + login_form["username"] = user.username + login_form["password"] = "password" + + # Handle 2FA + two_factor_page = login_form.submit().follow(status=HTTPStatus.OK) + two_factor_form = two_factor_page.forms["totp-auth-form"] + two_factor_form["csrf_token"] = csrf_token + two_factor_form["totp_value"] = ( + _get_totp(user.totp_secret).generate(time.time()).decode() + ) + two_factor_form.submit().follow(status=HTTPStatus.OK) + + # Navigate to organization publishing page + publishing_page = webtest.get( + f"/manage/organization/{organization.normalized_name}/publishing/", + status=HTTPStatus.OK, + ) + + # Get logged-in CSRF token + logged_in_csrf_token = publishing_page.html.find( + "input", {"name": "csrf_token"} + )["value"] + + # Fill out the Google pending publisher form + google_form = publishing_page.forms["pending-google-publisher-form"] + google_form["csrf_token"] = logged_in_csrf_token + google_form["project_name"] = "test-org-google-project" + google_form["email"] = "test@example.com" + google_form["sub"] = "" # Optional field + + # Submit the form, redirects back to the same page on success + response = google_form.submit(status=HTTPStatus.SEE_OTHER) + response.follow(status=HTTPStatus.OK) + + # Assert: Verify success + flash_messages = webtest.get( + "/_includes/unauthed/flash-messages/", status=HTTPStatus.OK + ) + success_message = flash_messages.html.find( + "span", {"class": "notification-bar__message"} + ) + assert success_message is not None + assert "Registered a new pending publisher" in success_message.text + assert "test-org-google-project" in success_message.text + assert organization.name in success_message.text + + @responses.activate + def test_add_pending_activestate_publisher_to_organization(self, webtest): + """ + An authenticated user who is an organization owner can add a pending + ActiveState trusted publisher to their organization. + """ + # Arrange: Create a user with an organization + user = UserFactory.create( + with_verified_primary_email=True, + with_terms_of_service_agreement=True, + clear_pwd="password", + ) + organization = OrganizationFactory.create(name="test-organization") + OrganizationRoleFactory.create( + user=user, + organization=organization, + role_name=OrganizationRoleType.Owner, + ) + + # Mock ActiveState API for organization and actor validation + # The form makes two sequential API calls: + # 1. Organization validation (validate_organization method) + # 2. Actor validation (validate_actor method) + responses.add( + responses.POST, + "https://platform.activestate.com/graphql/v1/graphql", + json={"data": {"organizations": [{"added": "2020-01-01"}]}}, + status=200, + ) + responses.add( + responses.POST, + "https://platform.activestate.com/graphql/v1/graphql", + json={"data": {"users": [{"user_id": "test-user-id"}]}}, + status=200, + ) + + # Act: Log in + login_page = webtest.get("/account/login/", status=HTTPStatus.OK) + login_form = login_page.forms["login-form"] + csrf_token = login_form["csrf_token"].value + login_form["username"] = user.username + login_form["password"] = "password" + + # Handle 2FA + two_factor_page = login_form.submit().follow(status=HTTPStatus.OK) + two_factor_form = two_factor_page.forms["totp-auth-form"] + two_factor_form["csrf_token"] = csrf_token + two_factor_form["totp_value"] = ( + _get_totp(user.totp_secret).generate(time.time()).decode() + ) + two_factor_form.submit().follow(status=HTTPStatus.OK) + + # Navigate to organization publishing page + publishing_page = webtest.get( + f"/manage/organization/{organization.normalized_name}/publishing/", + status=HTTPStatus.OK, + ) + + # Get logged-in CSRF token + logged_in_csrf_token = publishing_page.html.find( + "input", {"name": "csrf_token"} + )["value"] + + # Fill out the ActiveState pending publisher form + activestate_form = publishing_page.forms["pending-activestate-publisher-form"] + activestate_form["csrf_token"] = logged_in_csrf_token + activestate_form["project_name"] = "test-org-activestate-project" + activestate_form["organization"] = "test-activestate-org" + activestate_form["project"] = "test-activestate-project" + activestate_form["actor"] = "test-actor" + + # Submit the form, redirects back to the same page on success + response = activestate_form.submit(status=HTTPStatus.SEE_OTHER) + response.follow(status=HTTPStatus.OK) + + # Assert: Verify success + flash_messages = webtest.get( + "/_includes/unauthed/flash-messages/", status=HTTPStatus.OK + ) + success_message = flash_messages.html.find( + "span", {"class": "notification-bar__message"} + ) + assert success_message is not None + assert "Registered a new pending publisher" in success_message.text + assert "test-org-activestate-project" in success_message.text + assert organization.name in success_message.text diff --git a/tests/unit/manage/views/test_organizations.py b/tests/unit/manage/views/test_organizations.py index 846e8ecadb5f..ae7f89bda32c 100644 --- a/tests/unit/manage/views/test_organizations.py +++ b/tests/unit/manage/views/test_organizations.py @@ -8,10 +8,12 @@ from freezegun import freeze_time from paginate_sqlalchemy import SqlalchemyOrmPage as SQLAlchemyORMPage +from psycopg.errors import UniqueViolation from pyramid.httpexceptions import HTTPBadRequest, HTTPNotFound, HTTPSeeOther from webob.multidict import MultiDict from tests.common.db.accounts import EmailFactory, UserFactory +from tests.common.db.oidc import PendingGitHubPublisherFactory from tests.common.db.organizations import ( OrganizationApplicationFactory, OrganizationApplicationObservationFactory, @@ -32,6 +34,7 @@ ) from warehouse.accounts import ITokenService, IUserService from warehouse.accounts.interfaces import TokenExpired +from warehouse.admin.flags import AdminFlagValue from warehouse.authnz import Permissions from warehouse.manage import views from warehouse.manage.views import organizations as org_views @@ -3338,3 +3341,347 @@ def test_raises_404_with_out_of_range_page(self, db_request): with pytest.raises(HTTPNotFound): assert org_views.manage_organization_history(organization, db_request) + + +class TestManageOrganizationPublishingViews: + def test_manage_organization_publishing_get(self, db_request): + """Test GET request returns all forms and pending publishers""" + organization = OrganizationFactory.create() + user = UserFactory.create() + db_request.POST = MultiDict() + db_request.user = user + db_request.route_url = pretend.call_recorder(lambda *a, **kw: "/fake/route") + + view = org_views.ManageOrganizationPublishingViews(organization, db_request) + result = view.manage_organization_publishing() + + assert result["organization"] == organization + assert "pending_github_publisher_form" in result + assert "pending_gitlab_publisher_form" in result + assert "pending_google_publisher_form" in result + assert "pending_activestate_publisher_form" in result + assert result["pending_oidc_publishers"] == organization.pending_oidc_publishers + + def test_add_pending_github_oidc_publisher_success(self, db_request, monkeypatch): + """Test successfully adding a pending GitHub OIDC publisher""" + organization = OrganizationFactory.create() + user = UserFactory.create(with_verified_primary_email=True) + db_request.POST = MultiDict() + db_request.route_url = pretend.call_recorder(lambda *a, **kw: "/fake/route") + db_request.session = pretend.stub( + flash=pretend.call_recorder(lambda *a, **kw: None) + ) + db_request.user = user + + # Mock form + form = pretend.stub( + validate=pretend.call_recorder(lambda: True), + project_name=pretend.stub(data="test-project"), + repository=pretend.stub(data="test-repo"), + normalized_owner="test-owner", + owner_id="12345", + workflow_filename=pretend.stub(data="release.yml"), + normalized_environment="", + ) + monkeypatch.setattr( + org_views, "PendingGitHubPublisherForm", lambda *a, **kw: form + ) + + view = org_views.ManageOrganizationPublishingViews(organization, db_request) + result = view.add_pending_github_oidc_publisher() + + assert isinstance(result, HTTPSeeOther) + assert db_request.session.flash.calls == [ + pretend.call( + "Registered a new pending publisher to create the project " + f"'test-project' owned by the '{organization.name}' organization.", + queue="success", + ) + ] + assert db_request.metrics.increment.calls == [ + pretend.call( + "warehouse.oidc.add_pending_publisher.attempt", + tags=["publisher:GitHub", "organization:true"], + ), + pretend.call( + "warehouse.oidc.add_pending_publisher.ok", + tags=["publisher:GitHub", "organization:true"], + ), + ] + + def test_add_pending_github_oidc_publisher_disabled(self, db_request): + """Test adding GitHub publisher when admin flag disables it""" + organization = OrganizationFactory.create() + user = UserFactory.create(with_verified_primary_email=True) + db_request.user = user + db_request.flags = pretend.stub( + disallow_oidc=pretend.call_recorder( + lambda flag: flag == AdminFlagValue.DISALLOW_GITHUB_OIDC + ) + ) + db_request.session = pretend.stub( + flash=pretend.call_recorder(lambda *a, **kw: None) + ) + db_request.POST = MultiDict() + db_request.route_url = pretend.call_recorder(lambda *a, **kw: "/fake/route") + + view = org_views.ManageOrganizationPublishingViews(organization, db_request) + result = view.add_pending_github_oidc_publisher() + + assert result == view.default_response + assert db_request.session.flash.calls == [ + pretend.call( + "GitHub-based trusted publishing is temporarily disabled. " + "See https://pypi.org/help#admin-intervention for details.", + queue="error", + ) + ] + + def test_add_pending_github_oidc_publisher_over_limit(self, db_request): + """Adding GitHub publisher fails when org has too many pending publishers""" + organization = OrganizationFactory.create() + user = UserFactory.create(with_verified_primary_email=True) + # Add 3 existing pending publishers + PendingGitHubPublisherFactory.create_batch(3, organization_id=organization.id) + + db_request.user = user + db_request.session = pretend.stub( + flash=pretend.call_recorder(lambda *a, **kw: None) + ) + db_request.POST = MultiDict() + db_request.route_url = pretend.call_recorder(lambda *a, **kw: "/fake/route") + + view = org_views.ManageOrganizationPublishingViews(organization, db_request) + result = view.add_pending_github_oidc_publisher() + + assert result == view.default_response + assert db_request.session.flash.calls == [ + pretend.call( + "The trusted publisher could not be registered", + queue="error", + ) + ] + + def test_add_pending_gitlab_oidc_publisher_success(self, db_request, monkeypatch): + """Test successfully adding a pending GitLab OIDC publisher""" + organization = OrganizationFactory.create() + user = UserFactory.create(with_verified_primary_email=True) + db_request.flags = pretend.stub( + disallow_oidc=pretend.call_recorder(lambda *a: False) + ) + db_request.POST = MultiDict() + db_request.path = "/fake/path" + db_request.route_url = pretend.call_recorder(lambda *a, **kw: "/fake/route") + db_request.user = user + + # Mock form + form = pretend.stub( + validate=pretend.call_recorder(lambda: True), + project_name=pretend.stub(data="test-project"), + namespace=pretend.stub(data="test-namespace"), + project=pretend.stub(data="test-project"), + workflow_filepath=pretend.stub(data=".gitlab-ci.yml"), + environment=pretend.stub(data=""), + issuer_url=pretend.stub(data="https://gitlab.com"), + ) + monkeypatch.setattr( + org_views, "PendingGitLabPublisherForm", lambda *a, **kw: form + ) + + view = org_views.ManageOrganizationPublishingViews(organization, db_request) + result = view.add_pending_gitlab_oidc_publisher() + + assert isinstance(result, HTTPSeeOther) + assert db_request.metrics.increment.calls[-1] == pretend.call( + "warehouse.oidc.add_pending_publisher.ok", + tags=["publisher:GitLab", "organization:true"], + ) + + def test_gitlab_form_includes_issuer_url_choices(self, db_request, monkeypatch): + """Test that GitLab form is created with issuer_url_choices""" + organization = OrganizationFactory.create() + user = UserFactory.create() + db_request.POST = MultiDict() + db_request.user = user + db_request.route_url = pretend.call_recorder(lambda *a, **kw: "/fake/route") + + # Mock GitLabPublisher.get_available_issuer_urls to return multiple issuers + mock_issuers = [ + ("https://gitlab.com", "GitLab.com"), + ("https://gitlab.example.com", "Custom GitLab"), + ] + monkeypatch.setattr( + org_views.GitLabPublisher, + "get_available_issuer_urls", + lambda organization: mock_issuers, + ) + + # Track the form creation to verify issuer_url_choices are passed + form_calls = [] + + def track_form_creation(*args, **kwargs): + form_calls.append(kwargs) + return pretend.stub( + validate=lambda: False, + project_name=pretend.stub(data=""), + namespace=pretend.stub(data=""), + project=pretend.stub(data=""), + workflow_filepath=pretend.stub(data=""), + environment=pretend.stub(data=""), + issuer_url=pretend.stub(data="", choices=mock_issuers), + ) + + monkeypatch.setattr( + org_views, "PendingGitLabPublisherForm", track_form_creation + ) + + view = org_views.ManageOrganizationPublishingViews(organization, db_request) + + # Verify that the form was created with issuer_url_choices + assert len(form_calls) == 1 + assert form_calls[0]["issuer_url_choices"] == mock_issuers + + # Verify the form has the correct choices + assert view.pending_gitlab_publisher_form.issuer_url.choices == mock_issuers + + def test_manage_organization_publishing_get_oidc_disabled( + self, db_request, monkeypatch + ): + """Test GET request when global OIDC is disabled""" + organization = OrganizationFactory.create() + user = UserFactory.create() + db_request.POST = MultiDict() + db_request.user = user + db_request.route_url = pretend.call_recorder(lambda *a, **kw: "/fake/route") + db_request.flags = pretend.stub( + disallow_oidc=pretend.call_recorder(lambda f=None: True) + ) + db_request.session = pretend.stub( + flash=pretend.call_recorder(lambda *a, **kw: None) + ) + + # Mock all form classes since default_response tries to instantiate them + pending_github_publisher_form_obj = pretend.stub() + monkeypatch.setattr( + org_views, + "PendingGitHubPublisherForm", + lambda *a, **kw: pending_github_publisher_form_obj, + ) + pending_gitlab_publisher_form_obj = pretend.stub() + monkeypatch.setattr( + org_views, + "PendingGitLabPublisherForm", + lambda *a, **kw: pending_gitlab_publisher_form_obj, + ) + pending_google_publisher_form_obj = pretend.stub() + monkeypatch.setattr( + org_views, + "PendingGooglePublisherForm", + lambda *a, **kw: pending_google_publisher_form_obj, + ) + pending_activestate_publisher_form_obj = pretend.stub() + monkeypatch.setattr( + org_views, + "PendingActiveStatePublisherForm", + lambda *a, **kw: pending_activestate_publisher_form_obj, + ) + + view = org_views.ManageOrganizationPublishingViews(organization, db_request) + result = view.manage_organization_publishing() + + assert result["organization"] == organization + assert db_request.session.flash.calls == [ + pretend.call( + "Trusted publishing is temporarily disabled. " + "See https://pypi.org/help#admin-intervention for details.", + queue="error", + ) + ] + + def test_add_pending_github_oidc_publisher_already_exists( + self, db_request, monkeypatch + ): + """Test adding GitHub publisher when it already exists""" + organization = OrganizationFactory.create() + user = UserFactory.create(with_verified_primary_email=True) + + # Create an existing pending publisher with matching attributes + existing_publisher = PendingGitHubPublisherFactory.create( + project_name="test-project", + repository_name="test-repo", + repository_owner="test-owner", + repository_owner_id="12345", + workflow_filename="release.yml", + environment="", + organization_id=organization.id, + ) + db_request.db.add(existing_publisher) + db_request.db.flush() + + db_request.POST = MultiDict() + db_request.route_url = pretend.call_recorder(lambda *a, **kw: "/fake/route") + db_request.session = pretend.stub( + flash=pretend.call_recorder(lambda *a, **kw: None) + ) + db_request.user = user + + # Mock form with same data as existing publisher + form = pretend.stub( + validate=pretend.call_recorder(lambda: True), + project_name=pretend.stub(data="test-project"), + repository=pretend.stub(data="test-repo"), + normalized_owner="test-owner", + owner_id="12345", + workflow_filename=pretend.stub(data="release.yml"), + normalized_environment="", + ) + monkeypatch.setattr( + org_views, "PendingGitHubPublisherForm", lambda *a, **kw: form + ) + + view = org_views.ManageOrganizationPublishingViews(organization, db_request) + result = view.add_pending_github_oidc_publisher() + + assert result == view.default_response + assert db_request.session.flash.calls == [ + pretend.call( + "This trusted publisher has already been registered. " + "Please contact PyPI's admins if this wasn't intentional.", + queue="error", + ) + ] + + def test_add_pending_github_oidc_publisher_unique_violation( + self, db_request, monkeypatch + ): + """Test UniqueViolation exception handling during publisher creation""" + organization = OrganizationFactory.create() + user = UserFactory.create(with_verified_primary_email=True) + db_request.POST = MultiDict() + db_request.path = "/fake/path" + db_request.route_url = pretend.call_recorder(lambda *a, **kw: "/fake/route") + db_request.user = user + + # Mock db.add to raise UniqueViolation (simulates race condition) + db_request.db.add = pretend.raiser(UniqueViolation("foo", "bar", "baz")) + + # Mock form + form = pretend.stub( + validate=pretend.call_recorder(lambda: True), + project_name=pretend.stub(data="test-project"), + repository=pretend.stub(data="test-repo"), + normalized_owner="test-owner", + owner_id="12345", + workflow_filename=pretend.stub(data="release.yml"), + normalized_environment="", + ) + monkeypatch.setattr( + org_views, "PendingGitHubPublisherForm", lambda *a, **kw: form + ) + + view = org_views.ManageOrganizationPublishingViews(organization, db_request) + result = view.add_pending_github_oidc_publisher() + + # Should return HTTPSeeOther redirect (double-post protection) + assert isinstance(result, HTTPSeeOther) + assert result.location == "/fake/path" diff --git a/warehouse/manage/views/organizations.py b/warehouse/manage/views/organizations.py index db0f68e76a5f..76f63b66e93c 100644 --- a/warehouse/manage/views/organizations.py +++ b/warehouse/manage/views/organizations.py @@ -5,6 +5,7 @@ from urllib.parse import urljoin from paginate_sqlalchemy import SqlalchemyOrmPage as SQLAlchemyORMPage +from psycopg.errors import UniqueViolation from pyramid.httpexceptions import ( HTTPBadRequest, HTTPException, @@ -17,6 +18,7 @@ from warehouse.accounts.interfaces import ITokenService, IUserService, TokenExpired from warehouse.accounts.models import User +from warehouse.admin.flags import AdminFlagValue from warehouse.authnz import Permissions from warehouse.email import ( send_canceled_as_invited_organization_member_email, @@ -53,6 +55,19 @@ user_projects, ) from warehouse.observations.models import Observation +from warehouse.oidc.forms import ( + PendingActiveStatePublisherForm, + PendingGitHubPublisherForm, + PendingGitLabPublisherForm, + PendingGooglePublisherForm, +) +from warehouse.oidc.models import ( + GitLabPublisher, + PendingActiveStatePublisher, + PendingGitHubPublisher, + PendingGitLabPublisher, + PendingGooglePublisher, +) from warehouse.organizations import IOrganizationService from warehouse.organizations.models import ( Organization, @@ -1705,3 +1720,297 @@ def transfer_organization_project(project, request): return HTTPSeeOther( request.route_path("manage.project.settings", project_name=project.name) ) + + +@view_defaults( + route_name="manage.organization.publishing", + context=Organization, + renderer="manage/organization/publishing.html", + uses_session=True, + require_csrf=True, + require_methods=False, + permission=Permissions.OrganizationsManage, + has_translations=True, + require_reauth=True, +) +class ManageOrganizationPublishingViews: + def __init__(self, organization, request): + self.organization = organization + self.request = request + self.metrics = self.request.metrics + self.project_service = self.request.find_service(IProjectService) + self.pending_github_publisher_form = PendingGitHubPublisherForm( + self.request.POST, + api_token=self.request.registry.settings.get("github.token"), + route_url=self.request.route_url, + check_project_name=self.project_service.check_project_name, + user=request.user, # Still need to pass user for form validation + ) + _gl_issuers = GitLabPublisher.get_available_issuer_urls( + organization=organization + ) + self.pending_gitlab_publisher_form = PendingGitLabPublisherForm( + self.request.POST, + route_url=self.request.route_url, + check_project_name=self.project_service.check_project_name, + user=request.user, + issuer_url_choices=_gl_issuers, + ) + self.pending_google_publisher_form = PendingGooglePublisherForm( + self.request.POST, + route_url=self.request.route_url, + check_project_name=self.project_service.check_project_name, + user=request.user, + ) + self.pending_activestate_publisher_form = PendingActiveStatePublisherForm( + self.request.POST, + route_url=self.request.route_url, + check_project_name=self.project_service.check_project_name, + user=request.user, + ) + + @property + def default_response(self): + # Get pending publishers owned by this organization + pending_oidc_publishers = self.organization.pending_oidc_publishers + + return { + "organization": self.organization, + "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 + "pending_oidc_publishers": pending_oidc_publishers, + "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 + ), + "ActiveState": self.request.flags.disallow_oidc( + AdminFlagValue.DISALLOW_ACTIVESTATE_OIDC + ), + }, + } + + @view_config(request_method="GET") + def manage_organization_publishing(self): + if self.request.flags.disallow_oidc(): + self.request.session.flash( + self.request._( + "Trusted publishing is temporarily disabled. " + "See https://pypi.org/help#admin-intervention for details." + ), + queue="error", + ) + return self.default_response + + return self.default_response + + def _add_pending_oidc_publisher( + self, + publisher_name, + publisher_class, + admin_flag, + form, + make_pending_publisher, + make_existence_filters, + ): + """Common logic for adding organization-level pending OIDC publishers.""" + # Check admin flags + if self.request.flags.disallow_oidc(admin_flag): + self.request.session.flash( + self.request._( + f"{publisher_name}-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_pending_publisher.attempt", + tags=[f"publisher:{publisher_name}", "organization:true"], + ) + + # Validate form + if not form.validate(): + self.request.session.flash( + self.request._("The trusted publisher could not be registered"), + queue="error", + ) + return self.default_response + + # Check if publisher already exists + publisher_already_exists = ( + self.request.db.query(publisher_class) + .filter_by(**make_existence_filters(form)) + .first() + is not None + ) + + if publisher_already_exists: + self.request.session.flash( + self.request._( + "This trusted publisher has already been registered. " + "Please contact PyPI's admins if this wasn't intentional." + ), + queue="error", + ) + return self.default_response + + # Create pending publisher associated with organization + pending_publisher = make_pending_publisher(self.request, form) + + try: + self.request.db.add(pending_publisher) + self.request.db.flush() # To get the new ID + except UniqueViolation: + # Double-post protection + return HTTPSeeOther(self.request.path) + + # Record event on organization + self.organization.record_event( + tag=EventTag.Organization.PendingOIDCPublisherAdded, + request=self.request, + additional={ + "project": pending_publisher.project_name, + "publisher": pending_publisher.publisher_name, + "id": str(pending_publisher.id), + "specifier": str(pending_publisher), + "url": pending_publisher.publisher_url(), + "submitted_by": self.request.user.username, + }, + ) + + self.request.session.flash( + self.request._( + "Registered a new pending publisher to create " + f"the project '{pending_publisher.project_name}' " + f"owned by the '{self.organization.name}' organization." + ), + queue="success", + ) + + self.metrics.increment( + "warehouse.oidc.add_pending_publisher.ok", + tags=[f"publisher:{publisher_name}", "organization:true"], + ) + + return HTTPSeeOther(self.request.path) + + @view_config( + request_method="POST", request_param=PendingGitHubPublisherForm.__params__ + ) + def add_pending_github_oidc_publisher(self): + form = self.pending_github_publisher_form + return self._add_pending_oidc_publisher( + publisher_name="GitHub", + publisher_class=PendingGitHubPublisher, + admin_flag=AdminFlagValue.DISALLOW_GITHUB_OIDC, + form=form, + make_pending_publisher=lambda request, form: PendingGitHubPublisher( + project_name=form.project_name.data, + added_by=request.user, + repository_name=form.repository.data, + repository_owner=form.normalized_owner, + repository_owner_id=form.owner_id, + workflow_filename=form.workflow_filename.data, + environment=form.normalized_environment, + organization_id=self.organization.id, + ), + make_existence_filters=lambda form: dict( + project_name=form.project_name.data, + repository_name=form.repository.data, + repository_owner=form.normalized_owner, + workflow_filename=form.workflow_filename.data, + environment=form.normalized_environment, + ), + ) + + @view_config( + request_method="POST", request_param=PendingGitLabPublisherForm.__params__ + ) + def add_pending_gitlab_oidc_publisher(self): + form = self.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=request.user, + namespace=form.namespace.data, + project=form.project.data, + workflow_filepath=form.workflow_filepath.data, + environment=form.environment.data, + issuer_url=form.issuer_url.data, + organization_id=self.organization.id, + ), + make_existence_filters=lambda form: dict( + project_name=form.project_name.data, + namespace=form.namespace.data, + project=form.project.data, + workflow_filepath=form.workflow_filepath.data, + environment=form.environment.data, + issuer_url=form.issuer_url.data, + ), + ) + + @view_config( + request_method="POST", request_param=PendingGooglePublisherForm.__params__ + ) + def add_pending_google_oidc_publisher(self): + form = self.pending_google_publisher_form + return self._add_pending_oidc_publisher( + publisher_name="Google", + publisher_class=PendingGooglePublisher, + admin_flag=AdminFlagValue.DISALLOW_GOOGLE_OIDC, + form=form, + make_pending_publisher=lambda request, form: PendingGooglePublisher( + project_name=form.project_name.data, + added_by=request.user, + email=form.email.data, + sub=form.sub.data, + organization_id=self.organization.id, + ), + make_existence_filters=lambda form: dict( + project_name=form.project_name.data, + email=form.email.data, + sub=form.sub.data, + ), + ) + + @view_config( + request_method="POST", request_param=PendingActiveStatePublisherForm.__params__ + ) + def add_pending_activestate_oidc_publisher(self): + form = self.pending_activestate_publisher_form + return self._add_pending_oidc_publisher( + publisher_name="ActiveState", + publisher_class=PendingActiveStatePublisher, + admin_flag=AdminFlagValue.DISALLOW_ACTIVESTATE_OIDC, + form=form, + make_pending_publisher=lambda request, form: PendingActiveStatePublisher( + project_name=form.project_name.data, + added_by=request.user, + organization=form.organization.data, + activestate_project_name=form.project.data, + actor=form.actor.data, + actor_id=form.actor_id, + organization_id=self.organization.id, + ), + make_existence_filters=lambda form: dict( + project_name=form.project_name.data, + organization=form.organization.data, + activestate_project_name=form.project.data, + actor=form.actor.data, + actor_id=form.actor_id, + ), + ) diff --git a/warehouse/templates/includes/manage/manage-organization-menu.html b/warehouse/templates/includes/manage/manage-organization-menu.html index 9653ae4c370e..f301717477ff 100644 --- a/warehouse/templates/includes/manage/manage-organization-menu.html +++ b/warehouse/templates/includes/manage/manage-organization-menu.html @@ -29,6 +29,14 @@ {% if request.has_permission(Permissions.OrganizationsManage) %} +
  • + + + {% trans %}Publishing{% endtrans %} + +
  • + {% trans href="https://docs.github.com/en/actions/deployment/security-hardening-your-deployments/about-security-hardening-with-openid-connect" %}Read more about GitHub Actions' OpenID Connect support here.{% endtrans %} +

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

    + {% trans organization_name=organization.name %}The project (on PyPI) that will be created and owned by the '{{ organization_name }}' organization when this publisher is used{% endtrans %} +

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

    + {% trans %}The GitHub organization name or GitHub username that owns the repository{% endtrans %} +

    +
    {{ field_errors(pending_github_publisher_form.owner) }}
    +
    +
    + + {{ pending_github_publisher_form.repository(placeholder=gettext("repository") , + autocomplete="off", + autocapitalize="off", + spellcheck="false", + class_="form-group__field", + aria_describedby="repository-errors", + ) }} +

    + {% trans %}The name of the GitHub repository that contains the publishing workflow{% endtrans %} +

    +
    {{ field_errors(pending_github_publisher_form.repository) }}
    +
    +
    + + {{ pending_github_publisher_form.workflow_filename(placeholder=gettext("workflow.yml") , + class_="form-group__field", + autocomplete="off", + aria_describedby="workflow_filename-errors", + ) }} +

    + {% trans %}The filename of the publishing workflow. This file should exist in the .github/workflows/ directory in the repository configured above.{% endtrans %} +

    +
    {{ field_errors(pending_github_publisher_form.workflow_filename) }}
    +
    +
    + + {{ pending_github_publisher_form.environment(placeholder="testpypi" if testPyPI else "pypi", + class_="form-group__field", + autocomplete="off", + aria_describedby="environment-errors",) }} +

    + {% trans href="https://docs.github.com/en/actions/deployment/targeting-different-environments/using-environments-for-deployment" %} + The name of the GitHub Actions environment + that the above workflow uses for publishing. This should be + configured under the repository's settings. While not required, a + dedicated publishing environment is strongly + encouraged, especially if your repository has + maintainers with commit access who shouldn't have PyPI publishing + access. + {% endtrans %} +

    +
    {{ field_errors(pending_github_publisher_form.environment) }}
    +
    +
    + +
    +
    +{% endmacro %} +{% macro gitlab_form(request, pending_gitlab_publisher_form, organization) %} +

    + {% 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 organization_name=organization.name %}The project (on PyPI) that will be created and owned by the '{{ organization_name }}' organization 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 top-level pipeline, relative to the project's root. This file should exist in the project configured above (external pipelines 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) }}
    +
    +{% if pending_gitlab_publisher_form.issuer_url.choices|length > 1 %} +
    + + {{ pending_gitlab_publisher_form.issuer_url(class_="form-group__field", **{"aria-describedby":"issuer_url-errors"}) }} +

    + {% trans %}The GitLab instance URL. Select https://gitlab.com for the public GitLab service, or a custom instance.{% endtrans %} +

    +
    {{ field_errors(pending_gitlab_publisher_form.issuer_url) }}
    +
    +{% else %} + {# The following field must be present for the view predicate to match #} + {{ pending_gitlab_publisher_form.issuer_url(hidden=True) }} +{% endif %} +
    + +
    +
    +{% endmacro %} +{% macro google_form(request, pending_google_publisher_form, organization) %} +

    + {% trans href="https://cloud.google.com/iam/docs/service-account-creds" %} + Read more about Google's OpenID Connect support here. + {% endtrans %} +

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

    + {% trans organization_name=organization.name %}The project (on PyPI) that will be created and owned by the '{{ organization_name }}' organization when this publisher is used{% endtrans %} +

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

    + {% trans %}The email address of the account or service account used to publish.{% endtrans %} +

    +
    {{ field_errors(pending_google_publisher_form.email) }}
    +
    +
    + + {{ pending_google_publisher_form.sub(placeholder=gettext("subject") , + autocomplete="off", + autocapitalize="off", + spellcheck="false", + class_="form-group__field", + aria_describedby="sub-errors", + ) }} +

    + {% trans href="https://cloud.google.com/docs/authentication/token-types#id-contents" %}The subject is the numeric ID that represents the principal making the request. While not required, providing the subject further restricts the identity used for publishing. More details here.{% endtrans %} +

    +
    {{ field_errors(pending_google_publisher_form.sub) }}
    +
    +
    + +
    +
    +{% endmacro %} +{% macro activestate_form(request, pending_activestate_publisher_form, organization) %} +

    + {% trans href="https://docs.activestate.com/platform/user/oidc/" %} + Read more about ActiveState's OpenID Connect support here. + {% endtrans %} +

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

    + {% trans organization_name=organization.name %}The project (on PyPI) that will be created and owned by the '{{ organization_name }}' organization when this publisher is used{% endtrans %} +

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

    {% trans %}The ActiveState organization name that owns the project{% endtrans %}

    +
    {{ field_errors(pending_activestate_publisher_form.organization) }}
    +
    +
    + + {{ pending_activestate_publisher_form.project(placeholder=gettext("my-project") , + autocomplete="off", + autocapitalize="off", + spellcheck="false", + class_="form-group__field", + aria_describedby="project-errors") + }} +

    + {% trans %}The ActiveState project that will build your Python artifact.{% endtrans %} +

    +
    {{ field_errors(pending_activestate_publisher_form.project) }}
    +
    +
    + + {{ pending_activestate_publisher_form.actor(placeholder=gettext("my-username") , + class_="form-group__field", + autocomplete="off", + aria_describedby="actor-errors") + }} +

    + {% trans %}The username for the ActiveState account that will trigger the build of your Python artifact.{% endtrans %} +

    +
    {{ field_errors(pending_activestate_publisher_form.actor) }}
    +
    +
    + +
    +
    +{% endmacro %} +{% block main %} +
    +

    {{ oidc_title() }}

    + {{ oidc_desc() }} +
    +

    + {% trans %} + Tip: Trusted publishers created here will be owned by this organization when the project is created. + Organization owners can manage these publishers and their associated projects. + {% endtrans %} +

    +
    +

    {% trans %}Pending publishers{% endtrans %}

    +

    + {% trans %} + Pending publishers are trusted publishers for projects that do not exist yet. + They can be used to create a new project on first upload. + {% endtrans %} +

    +{% if pending_github_publisher_form or pending_gitlab_publisher_form or pending_google_publisher_form or pending_activestate_publisher_form %} + {% if pending_oidc_publishers %} + + + + + + + + + + + + {% for publisher in pending_oidc_publishers %}{{ oidc_publisher_row(publisher) }}{% endfor %} + +
    {% trans %}Pending publishers for this organization{% endtrans %}
    {% trans %}Pending project name{% endtrans %}{% trans %}Publisher{% endtrans %}{% trans %}Details{% endtrans %}
    + {% else %} +

    + {% trans %}No pending publishers are currently configured. Publishers for projects that don't exist yet can be added below.{% endtrans %} +

    + {% endif %} +
    +
    +

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

    +

    {% trans %}You can use this page to register "pending" trusted publishers.{% endtrans %}

    +

    + {% trans href="https://docs.pypi.org/trusted-publishers/" %} + These publishers behave similarly to trusted publishers registered + against specific projects, except that they allow users to create + the project if it doesn't already exist. Once the project is created, + the "pending" publisher becomes an ordinary trusted publisher. + You can read more about "pending" and ordinary trusted publishers + here. + {% endtrans %} +

    +

    + {% trans %} + Configuring a "pending" publisher for a project name does not reserve + that name. Until the project is created, any other user may create it, + including via their own "pending" publisher. +{% endtrans %} +

    +{% set publishers = [ + ("GitHub", github_form(request, pending_github_publisher_form, organization)), + ("GitLab", gitlab_form(request, pending_gitlab_publisher_form, organization)), + ("Google", google_form(request, pending_google_publisher_form, organization)), + ("ActiveState", activestate_form(request, pending_activestate_publisher_form, organization)), +] %} +
    +
    + {% for publisher_name, _ in publishers %} + {% if not disabled[publisher_name] %} + + {% endif %} + {% endfor %} +
    + {% for publisher_name, publisher_form in publishers %} + {% if not disabled[publisher_name] %} +
    {{ publisher_form }}
    + {% endif %} + {% endfor %} +
    +{% else %} +

    + {% trans %} + You must be an organization owner to manage trusted publishers. +{% endtrans %} +

    +{% endif %} +
    +{% endblock %} From a081651bc9a17fb3cd5fd9454dc9fcb897b3ee8c Mon Sep 17 00:00:00 2001 From: Mike Fiedler Date: Wed, 5 Nov 2025 17:26:53 -0500 Subject: [PATCH 4/6] feat: update mint_token for org-owned publishers Signed-off-by: Mike Fiedler --- tests/unit/oidc/test_views.py | 70 +++++++++++++++++++++++++++++++++++ warehouse/oidc/views.py | 33 ++++++++++++++--- 2 files changed, 97 insertions(+), 6 deletions(-) diff --git a/tests/unit/oidc/test_views.py b/tests/unit/oidc/test_views.py index c6b17ee5b68d..26990309d234 100644 --- a/tests/unit/oidc/test_views.py +++ b/tests/unit/oidc/test_views.py @@ -15,6 +15,7 @@ GooglePublisherFactory, PendingGitHubPublisherFactory, ) +from tests.common.db.organizations import OrganizationFactory from tests.common.db.packaging import ProhibitedProjectFactory, ProjectFactory from warehouse.events.tags import EventTag from warehouse.macaroons import caveats @@ -27,6 +28,7 @@ is_from_reusable_workflow, should_send_environment_warning_email, ) +from warehouse.organizations.models import OrganizationProject from warehouse.packaging import services from warehouse.packaging.models import Project from warehouse.rate_limiting.interfaces import IRateLimiter @@ -508,6 +510,74 @@ def test_mint_token_from_oidc_pending_publisher_ok(monkeypatch, db_request): } +def test_mint_token_from_oidc_pending_publisher_for_organization_ok( + monkeypatch, db_request +): + """Test creating a project from an organization-owned pending publisher""" + user = UserFactory.create() + organization = OrganizationFactory.create() + + pending_publisher = PendingGitHubPublisherFactory.create( + project_name="org-owned-project", + added_by=user, + repository_name="bar", + repository_owner="foo", + repository_owner_id="123", + workflow_filename="example.yml", + environment="fake", + organization_id=organization.id, + ) + + db_request.flags.disallow_oidc = lambda f=None: False + db_request.body = json.dumps({"token": DUMMY_GITHUB_OIDC_JWT}) + db_request.remote_addr = "0.0.0.0" + + ratelimiter = pretend.stub(clear=pretend.call_recorder(lambda id: None)) + ratelimiters = { + "user.oidc": ratelimiter, + "ip.oidc": ratelimiter, + } + monkeypatch.setattr(views, "_ratelimiters", lambda r: ratelimiters) + + resp = views.mint_token_from_oidc(db_request) + assert resp["success"] + assert resp["token"].startswith("pypi-") + + # Verify project was created + project = ( + db_request.db.query(Project) + .filter(Project.name == pending_publisher.project_name) + .one() + ) + + # Verify project is associated with organization + org_project = ( + db_request.db.query(OrganizationProject) + .filter( + OrganizationProject.organization_id == organization.id, + OrganizationProject.project_id == project.id, + ) + .one() + ) + assert org_project.organization_id == organization.id + assert org_project.project_id == project.id + + # Verify publisher was created + publisher = db_request.db.query(GitHubPublisher).one() + event = project.events.where( + Project.Event.tag == EventTag.Project.OIDCPublisherAdded + ).one() + assert event.additional == { + "publisher": publisher.publisher_name, + "id": str(publisher.id), + "specifier": str(publisher), + "url": publisher.publisher_url(), + "submitted_by": "OpenID created token", + "reified_from_pending_publisher": True, + "constrained_from_existing_publisher": False, + } + + def test_mint_token_from_pending_trusted_publisher_invalidates_others( monkeypatch, db_request ): diff --git a/warehouse/oidc/views.py b/warehouse/oidc/views.py index 261def5da8f8..fd58e2cee320 100644 --- a/warehouse/oidc/views.py +++ b/warehouse/oidc/views.py @@ -29,6 +29,7 @@ OIDC_ISSUER_SERVICE_NAMES, lookup_custom_issuer_type, ) +from warehouse.organizations.models import OrganizationProject from warehouse.packaging.interfaces import IProjectService from warehouse.packaging.models import ProjectFactory from warehouse.rate_limiting.interfaces import IRateLimiter @@ -217,12 +218,32 @@ def mint_token( # Try creating the new project project_service = request.find_service(IProjectService) try: - new_project = project_service.create_project( - pending_publisher.project_name, - pending_publisher.added_by, - request, - ratelimited=False, - ) + # Check if this pending publisher is for an organization + if pending_publisher.organization_id: + # For organization-owned projects, + # create without making the user an owner + new_project = project_service.create_project( + pending_publisher.project_name, + pending_publisher.added_by, + request, + creator_is_owner=False, + ratelimited=False, + ) + # Add the project to the organization + request.db.add( + OrganizationProject( + organization_id=pending_publisher.organization_id, + project_id=new_project.id, + ) + ) + else: + # For user-owned projects, create normally + new_project = project_service.create_project( + pending_publisher.project_name, + pending_publisher.added_by, + request, + ratelimited=False, + ) except HTTPException as exc: return _invalid( errors=[{"code": "invalid-payload", "description": str(exc)}], From 5c4545b1b7ee17f81f468e0c4df4d2c4f998c8c3 Mon Sep 17 00:00:00 2001 From: Mike Fiedler Date: Wed, 5 Nov 2025 17:27:15 -0500 Subject: [PATCH 5/6] test: silence deprecation warning on package scope Signed-off-by: Mike Fiedler --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 74a8166c19ae..8895425178ec 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -116,7 +116,7 @@ filterwarnings = [ # https://github.com/Pylons/pyramid/issues/3731 'ignore:pkg_resources is deprecated as an API.*:UserWarning:pyramid.asset', # https://github.com/Pylons/webob/issues/473 - 'ignore:datetime\.datetime\.utcnow\(\) is deprecated.*:DeprecationWarning:webob.cookies', + 'ignore:datetime\.datetime\.utcnow\(\) is deprecated.*:DeprecationWarning:webob.*', # https://github.com/pypi/warehouse/issues/15454#issuecomment-2599321232 'ignore:Accessing argon2.__version__ is deprecated.*:DeprecationWarning:passlib.handlers.argon2', # https://github.com/zopefoundation/meta/issues/194 From 76b04a4419c7780de965bb2ee17891f4e079adad Mon Sep 17 00:00:00 2001 From: Mike Fiedler Date: Wed, 5 Nov 2025 17:38:38 -0500 Subject: [PATCH 6/6] make translations Signed-off-by: Mike Fiedler --- warehouse/locale/messages.pot | 188 +++++++++++++++++++++++++++++----- 1 file changed, 161 insertions(+), 27 deletions(-) diff --git a/warehouse/locale/messages.pot b/warehouse/locale/messages.pot index 8fdbd270e5e3..f1bee37209bf 100644 --- a/warehouse/locale/messages.pot +++ b/warehouse/locale/messages.pot @@ -307,12 +307,14 @@ msgstr "" #: warehouse/accounts/views.py:1674 warehouse/accounts/views.py:1928 #: warehouse/manage/views/oidc_publishers.py:126 +#: warehouse/manage/views/organizations.py:1805 msgid "" "Trusted publishing is temporarily disabled. See https://pypi.org/help" "#admin-intervention for details." msgstr "" #: warehouse/accounts/views.py:1695 +#: warehouse/manage/views/organizations.py:1828 msgid "disabled. See https://pypi.org/help#admin-intervention for details." msgstr "" @@ -341,16 +343,19 @@ msgstr "" #: warehouse/manage/views/oidc_publishers.py:436 #: warehouse/manage/views/oidc_publishers.py:552 #: warehouse/manage/views/oidc_publishers.py:664 +#: warehouse/manage/views/organizations.py:1844 msgid "The trusted publisher could not be registered" msgstr "" #: warehouse/accounts/views.py:1764 +#: warehouse/manage/views/organizations.py:1860 msgid "" "This trusted publisher has already been registered. Please contact PyPI's" " admins if this wasn't intentional." msgstr "" #: warehouse/accounts/views.py:1798 +#: warehouse/manage/views/organizations.py:1893 msgid "Registered a new pending publisher to create " msgstr "" @@ -599,13 +604,13 @@ msgid "" msgstr "" #: warehouse/manage/views/__init__.py:2173 -#: warehouse/manage/views/organizations.py:962 +#: warehouse/manage/views/organizations.py:977 #, python-brace-format msgid "User '${username}' already has an active invite. Please try again later." msgstr "" #: warehouse/manage/views/__init__.py:2238 -#: warehouse/manage/views/organizations.py:1037 +#: warehouse/manage/views/organizations.py:1052 #, python-brace-format msgid "Invitation sent to '${username}'" msgstr "" @@ -619,7 +624,7 @@ msgid "Invitation already expired." msgstr "" #: warehouse/manage/views/__init__.py:2314 -#: warehouse/manage/views/organizations.py:1224 +#: warehouse/manage/views/organizations.py:1239 #, python-brace-format msgid "Invitation revoked from '${username}'." msgstr "" @@ -652,32 +657,32 @@ msgid "" "https://pypi.org/help#admin-intervention for details." msgstr "" -#: warehouse/manage/views/organizations.py:938 +#: warehouse/manage/views/organizations.py:953 #, python-brace-format msgid "User '${username}' already has ${role_name} role for organization" msgstr "" -#: warehouse/manage/views/organizations.py:949 +#: warehouse/manage/views/organizations.py:964 #, python-brace-format msgid "" "User '${username}' does not have a verified primary email address and " "cannot be added as a ${role_name} for organization" msgstr "" -#: warehouse/manage/views/organizations.py:973 +#: warehouse/manage/views/organizations.py:988 msgid "Cannot invite new member. Organization is not in good standing." msgstr "" -#: warehouse/manage/views/organizations.py:1119 -#: warehouse/manage/views/organizations.py:1161 +#: warehouse/manage/views/organizations.py:1134 +#: warehouse/manage/views/organizations.py:1176 msgid "Could not find organization invitation." msgstr "" -#: warehouse/manage/views/organizations.py:1129 +#: warehouse/manage/views/organizations.py:1144 msgid "Organization invitation could not be re-sent." msgstr "" -#: warehouse/manage/views/organizations.py:1177 +#: warehouse/manage/views/organizations.py:1192 #, python-brace-format msgid "Expired invitation for '${username}' deleted." msgstr "" @@ -1575,6 +1580,24 @@ msgstr "" #: warehouse/templates/manage/organization/activate_subscription.html:22 #: warehouse/templates/manage/organization/projects.html:109 #: warehouse/templates/manage/organization/projects.html:130 +#: warehouse/templates/manage/organization/publishing.html:23 +#: warehouse/templates/manage/organization/publishing.html:36 +#: warehouse/templates/manage/organization/publishing.html:49 +#: warehouse/templates/manage/organization/publishing.html:68 +#: warehouse/templates/manage/organization/publishing.html:85 +#: warehouse/templates/manage/organization/publishing.html:132 +#: warehouse/templates/manage/organization/publishing.html:145 +#: warehouse/templates/manage/organization/publishing.html:158 +#: warehouse/templates/manage/organization/publishing.html:171 +#: warehouse/templates/manage/organization/publishing.html:184 +#: warehouse/templates/manage/organization/publishing.html:208 +#: warehouse/templates/manage/organization/publishing.html:246 +#: warehouse/templates/manage/organization/publishing.html:259 +#: warehouse/templates/manage/organization/publishing.html:272 +#: warehouse/templates/manage/organization/publishing.html:314 +#: warehouse/templates/manage/organization/publishing.html:333 +#: warehouse/templates/manage/organization/publishing.html:350 +#: warehouse/templates/manage/organization/publishing.html:369 #: warehouse/templates/manage/organization/roles.html:398 #: warehouse/templates/manage/organization/roles.html:417 #: warehouse/templates/manage/organization/settings.html:35 @@ -3085,6 +3108,7 @@ msgstr "" #: warehouse/templates/includes/accounts/profile-public-email.html:16 #: warehouse/templates/manage/account/publishing.html:243 +#: warehouse/templates/manage/organization/publishing.html:257 #: warehouse/templates/manage/project/publishing.html:242 msgid "Email" msgstr "" @@ -3112,6 +3136,13 @@ msgid "Teams" msgstr "" #: warehouse/templates/includes/manage/manage-organization-menu.html:37 +#: warehouse/templates/includes/manage/manage-project-menu.html:49 +#: warehouse/templates/manage/manage_base.html:265 +#: warehouse/templates/manage/manage_base.html:307 +msgid "Publishing" +msgstr "" + +#: warehouse/templates/includes/manage/manage-organization-menu.html:45 #: warehouse/templates/includes/manage/manage-project-menu.html:31 #: warehouse/templates/includes/manage/manage-team-menu.html:28 #: warehouse/templates/manage/account.html:513 @@ -3122,7 +3153,7 @@ msgstr "" msgid "Security history" msgstr "" -#: warehouse/templates/includes/manage/manage-organization-menu.html:46 +#: warehouse/templates/includes/manage/manage-organization-menu.html:54 #: warehouse/templates/includes/manage/manage-project-menu.html:57 #: warehouse/templates/includes/manage/manage-team-menu.html:37 msgid "Settings" @@ -3149,12 +3180,6 @@ msgstr "" msgid "Documentation" msgstr "" -#: warehouse/templates/includes/manage/manage-project-menu.html:49 -#: warehouse/templates/manage/manage_base.html:265 -#: warehouse/templates/manage/manage_base.html:307 -msgid "Publishing" -msgstr "" - #: warehouse/templates/includes/manage/manage-team-menu.html:2 #, python-format msgid "Navigation for managing %(team)s" @@ -4451,6 +4476,7 @@ msgid "Manager" msgstr "" #: warehouse/templates/manage/account/publishing.html:36 +#: warehouse/templates/manage/organization/publishing.html:34 #: warehouse/templates/manage/organization/roles.html:49 #: warehouse/templates/manage/organization/roles.html:234 #: warehouse/templates/manage/organizations.html:91 @@ -4733,6 +4759,7 @@ msgid "" msgstr "" #: warehouse/templates/manage/account/publishing.html:9 +#: warehouse/templates/manage/organization/publishing.html:9 #: warehouse/templates/manage/project/publishing.html:28 #, python-format msgid "" @@ -4744,6 +4771,10 @@ msgstr "" #: warehouse/templates/manage/account/publishing.html:132 #: warehouse/templates/manage/account/publishing.html:230 #: warehouse/templates/manage/account/publishing.html:298 +#: warehouse/templates/manage/organization/publishing.html:21 +#: warehouse/templates/manage/organization/publishing.html:130 +#: warehouse/templates/manage/organization/publishing.html:244 +#: warehouse/templates/manage/organization/publishing.html:312 msgid "PyPI Project Name" msgstr "" @@ -4751,6 +4782,10 @@ msgstr "" #: warehouse/templates/manage/account/publishing.html:137 #: warehouse/templates/manage/account/publishing.html:235 #: warehouse/templates/manage/account/publishing.html:303 +#: warehouse/templates/manage/organization/publishing.html:26 +#: warehouse/templates/manage/organization/publishing.html:135 +#: warehouse/templates/manage/organization/publishing.html:249 +#: warehouse/templates/manage/organization/publishing.html:317 msgid "project name" msgstr "" @@ -4762,41 +4797,49 @@ msgid "The project (on PyPI) that will be created when this publisher is used" msgstr "" #: warehouse/templates/manage/account/publishing.html:41 +#: warehouse/templates/manage/organization/publishing.html:39 #: warehouse/templates/manage/project/publishing.html:47 msgid "owner" msgstr "" #: warehouse/templates/manage/account/publishing.html:43 +#: warehouse/templates/manage/organization/publishing.html:41 #: warehouse/templates/manage/project/publishing.html:49 msgid "The GitHub organization name or GitHub username that owns the repository" msgstr "" #: warehouse/templates/manage/account/publishing.html:49 +#: warehouse/templates/manage/organization/publishing.html:47 #: warehouse/templates/manage/project/publishing.html:55 msgid "Repository name" msgstr "" #: warehouse/templates/manage/account/publishing.html:54 +#: warehouse/templates/manage/organization/publishing.html:52 #: warehouse/templates/manage/project/publishing.html:60 msgid "repository" msgstr "" #: warehouse/templates/manage/account/publishing.html:62 +#: warehouse/templates/manage/organization/publishing.html:60 #: warehouse/templates/manage/project/publishing.html:68 msgid "The name of the GitHub repository that contains the publishing workflow" msgstr "" #: warehouse/templates/manage/account/publishing.html:68 +#: warehouse/templates/manage/organization/publishing.html:66 #: warehouse/templates/manage/project/publishing.html:74 msgid "Workflow name" msgstr "" #: warehouse/templates/manage/account/publishing.html:73 +#: warehouse/templates/manage/organization/publishing.html:71 #: warehouse/templates/manage/project/publishing.html:79 msgid "workflow.yml" msgstr "" #: warehouse/templates/manage/account/publishing.html:79 +#: warehouse/templates/manage/organization/publishing.html:77 #: warehouse/templates/manage/project/publishing.html:85 msgid "" "The filename of the publishing workflow. This file should exist in the " @@ -4806,6 +4849,8 @@ msgstr "" #: warehouse/templates/manage/account/publishing.html:85 #: warehouse/templates/manage/account/publishing.html:184 +#: warehouse/templates/manage/organization/publishing.html:83 +#: warehouse/templates/manage/organization/publishing.html:182 #: warehouse/templates/manage/project/publishing.html:91 #: warehouse/templates/manage/project/publishing.html:179 msgid "Environment name" @@ -4814,6 +4859,9 @@ msgstr "" #: warehouse/templates/manage/account/publishing.html:89 #: warehouse/templates/manage/account/publishing.html:188 #: warehouse/templates/manage/account/publishing.html:260 +#: warehouse/templates/manage/organization/publishing.html:87 +#: warehouse/templates/manage/organization/publishing.html:186 +#: warehouse/templates/manage/organization/publishing.html:274 #: warehouse/templates/manage/project/publishing.html:95 #: warehouse/templates/manage/project/publishing.html:183 #: warehouse/templates/manage/project/publishing.html:259 @@ -4821,6 +4869,7 @@ msgid "(optional)" msgstr "" #: warehouse/templates/manage/account/publishing.html:97 +#: warehouse/templates/manage/organization/publishing.html:95 #: warehouse/templates/manage/project/publishing.html:104 #, python-format msgid "" @@ -4836,6 +4885,10 @@ msgstr "" #: warehouse/templates/manage/account/publishing.html:209 #: warehouse/templates/manage/account/publishing.html:277 #: warehouse/templates/manage/account/publishing.html:370 +#: warehouse/templates/manage/organization/publishing.html:109 +#: warehouse/templates/manage/organization/publishing.html:223 +#: warehouse/templates/manage/organization/publishing.html:291 +#: warehouse/templates/manage/organization/publishing.html:384 #: warehouse/templates/manage/project/publishing.html:118 #: warehouse/templates/manage/project/publishing.html:220 #: warehouse/templates/manage/project/publishing.html:276 @@ -4847,6 +4900,7 @@ msgid "Add" msgstr "" #: warehouse/templates/manage/account/publishing.html:118 +#: warehouse/templates/manage/organization/publishing.html:116 #: warehouse/templates/manage/project/publishing.html:126 #, python-format msgid "" @@ -4855,16 +4909,19 @@ msgid "" msgstr "" #: warehouse/templates/manage/account/publishing.html:145 +#: warehouse/templates/manage/organization/publishing.html:143 #: warehouse/templates/manage/project/publishing.html:140 msgid "Namespace" msgstr "" #: warehouse/templates/manage/account/publishing.html:150 +#: warehouse/templates/manage/organization/publishing.html:148 #: warehouse/templates/manage/project/publishing.html:145 msgid "namespace" msgstr "" #: warehouse/templates/manage/account/publishing.html:152 +#: warehouse/templates/manage/organization/publishing.html:150 #: warehouse/templates/manage/project/publishing.html:147 msgid "" "The GitLab username or GitLab group/subgroup namespace that the project " @@ -4872,6 +4929,7 @@ msgid "" msgstr "" #: warehouse/templates/manage/account/publishing.html:158 +#: warehouse/templates/manage/organization/publishing.html:156 #: warehouse/templates/manage/project/documentation.html:21 #: warehouse/templates/manage/project/publishing.html:153 #: warehouse/templates/manage/project/release.html:139 @@ -4880,26 +4938,31 @@ msgid "Project name" msgstr "" #: warehouse/templates/manage/account/publishing.html:163 +#: warehouse/templates/manage/organization/publishing.html:161 #: warehouse/templates/manage/project/publishing.html:158 msgid "project" msgstr "" #: warehouse/templates/manage/account/publishing.html:165 +#: warehouse/templates/manage/organization/publishing.html:163 #: warehouse/templates/manage/project/publishing.html:160 msgid "The name of the GitLab project that contains the publishing workflow" msgstr "" #: warehouse/templates/manage/account/publishing.html:171 +#: warehouse/templates/manage/organization/publishing.html:169 #: warehouse/templates/manage/project/publishing.html:166 msgid "Top-level pipeline file path" msgstr "" #: warehouse/templates/manage/account/publishing.html:176 +#: warehouse/templates/manage/organization/publishing.html:174 #: warehouse/templates/manage/project/publishing.html:171 msgid ".gitlab-ci.yml" msgstr "" #: warehouse/templates/manage/account/publishing.html:178 +#: warehouse/templates/manage/organization/publishing.html:176 #: warehouse/templates/manage/project/publishing.html:173 msgid "" "The file path of the top-level pipeline, relative to the project's root. " @@ -4908,12 +4971,14 @@ msgid "" msgstr "" #: warehouse/templates/manage/account/publishing.html:191 +#: warehouse/templates/manage/organization/publishing.html:189 #: warehouse/templates/manage/project/publishing.html:98 #: warehouse/templates/manage/project/publishing.html:186 msgid "release" msgstr "" #: warehouse/templates/manage/account/publishing.html:193 +#: warehouse/templates/manage/organization/publishing.html:191 #: warehouse/templates/manage/project/publishing.html:188 #, python-format msgid "" @@ -4926,6 +4991,7 @@ msgid "" msgstr "" #: warehouse/templates/manage/account/publishing.html:216 +#: warehouse/templates/manage/organization/publishing.html:230 #: warehouse/templates/manage/project/publishing.html:228 #, python-format msgid "" @@ -4934,26 +5000,31 @@ msgid "" msgstr "" #: warehouse/templates/manage/account/publishing.html:248 +#: warehouse/templates/manage/organization/publishing.html:262 #: warehouse/templates/manage/project/publishing.html:247 msgid "email" msgstr "" #: warehouse/templates/manage/account/publishing.html:250 +#: warehouse/templates/manage/organization/publishing.html:264 #: warehouse/templates/manage/project/publishing.html:249 msgid "The email address of the account or service account used to publish." msgstr "" #: warehouse/templates/manage/account/publishing.html:256 +#: warehouse/templates/manage/organization/publishing.html:270 #: warehouse/templates/manage/project/publishing.html:255 msgid "Subject" msgstr "" #: warehouse/templates/manage/account/publishing.html:263 +#: warehouse/templates/manage/organization/publishing.html:277 #: warehouse/templates/manage/project/publishing.html:262 msgid "subject" msgstr "" #: warehouse/templates/manage/account/publishing.html:271 +#: warehouse/templates/manage/organization/publishing.html:285 #, python-format msgid "" "The subject is the numeric ID that represents the principal making the " @@ -4962,6 +5033,7 @@ msgid "" msgstr "" #: warehouse/templates/manage/account/publishing.html:284 +#: warehouse/templates/manage/organization/publishing.html:298 #: warehouse/templates/manage/project/publishing.html:284 #, python-format msgid "" @@ -4976,41 +5048,49 @@ msgid "Organization" msgstr "" #: warehouse/templates/manage/account/publishing.html:322 +#: warehouse/templates/manage/organization/publishing.html:336 #: warehouse/templates/manage/project/publishing.html:303 msgid "my-organization" msgstr "" #: warehouse/templates/manage/account/publishing.html:329 +#: warehouse/templates/manage/organization/publishing.html:343 #: warehouse/templates/manage/project/publishing.html:310 msgid "The ActiveState organization name that owns the project" msgstr "" #: warehouse/templates/manage/account/publishing.html:334 +#: warehouse/templates/manage/organization/publishing.html:348 #: warehouse/templates/manage/project/publishing.html:315 msgid "ActiveState Project name" msgstr "" #: warehouse/templates/manage/account/publishing.html:339 +#: warehouse/templates/manage/organization/publishing.html:353 #: warehouse/templates/manage/project/publishing.html:320 msgid "my-project" msgstr "" #: warehouse/templates/manage/account/publishing.html:347 +#: warehouse/templates/manage/organization/publishing.html:361 #: warehouse/templates/manage/project/publishing.html:328 msgid "The ActiveState project that will build your Python artifact." msgstr "" #: warehouse/templates/manage/account/publishing.html:353 +#: warehouse/templates/manage/organization/publishing.html:367 #: warehouse/templates/manage/project/publishing.html:334 msgid "Actor Username" msgstr "" #: warehouse/templates/manage/account/publishing.html:358 +#: warehouse/templates/manage/organization/publishing.html:372 #: warehouse/templates/manage/project/publishing.html:339 msgid "my-username" msgstr "" #: warehouse/templates/manage/account/publishing.html:364 +#: warehouse/templates/manage/organization/publishing.html:378 #: warehouse/templates/manage/project/publishing.html:345 msgid "" "The username for the ActiveState account that will trigger the build of " @@ -5032,34 +5112,41 @@ msgid "" msgstr "" #: warehouse/templates/manage/account/publishing.html:416 +#: warehouse/templates/manage/organization/publishing.html:414 msgid "Pending project name" msgstr "" #: warehouse/templates/manage/account/publishing.html:417 +#: warehouse/templates/manage/organization/publishing.html:415 #: warehouse/templates/manage/project/publishing.html:375 msgid "Publisher" msgstr "" #: warehouse/templates/manage/account/publishing.html:418 +#: warehouse/templates/manage/organization/publishing.html:416 #: warehouse/templates/manage/project/publishing.html:376 msgid "Details" msgstr "" #: warehouse/templates/manage/account/publishing.html:428 +#: warehouse/templates/manage/organization/publishing.html:426 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:435 +#: warehouse/templates/manage/organization/publishing.html:431 msgid "Add a new pending publisher" msgstr "" #: warehouse/templates/manage/account/publishing.html:437 +#: warehouse/templates/manage/organization/publishing.html:432 msgid "You can use this page to register \"pending\" trusted publishers." msgstr "" #: warehouse/templates/manage/account/publishing.html:442 +#: warehouse/templates/manage/organization/publishing.html:434 #, python-format msgid "" "These publishers behave similarly to trusted publishers registered " @@ -5071,6 +5158,7 @@ msgid "" msgstr "" #: warehouse/templates/manage/account/publishing.html:452 +#: warehouse/templates/manage/organization/publishing.html:444 msgid "" "Configuring a \"pending\" publisher for a project name does " "not reserve that name. Until the project is created, any" @@ -5803,6 +5891,62 @@ msgstr "" msgid "Create and add new project" msgstr "" +#: warehouse/templates/manage/organization/publishing.html:5 +#, python-format +msgid "Trusted Publisher Management for '%(organization_name)s'" +msgstr "" + +#: warehouse/templates/manage/organization/publishing.html:28 +#: warehouse/templates/manage/organization/publishing.html:137 +#: warehouse/templates/manage/organization/publishing.html:251 +#: warehouse/templates/manage/organization/publishing.html:325 +#, python-format +msgid "" +"The project (on PyPI) that will be created and owned by the " +"'%(organization_name)s' organization when this publisher is used" +msgstr "" + +#: warehouse/templates/manage/organization/publishing.html:206 +#: warehouse/templates/manage/project/publishing.html:203 +msgid "GitLab instance" +msgstr "" + +#: warehouse/templates/manage/organization/publishing.html:213 +#: warehouse/templates/manage/project/publishing.html:210 +msgid "" +"The GitLab instance URL. Select https://gitlab.com for the " +"public GitLab service, or a custom instance." +msgstr "" + +#: warehouse/templates/manage/organization/publishing.html:331 +msgid "ActiveState Organization" +msgstr "" + +#: warehouse/templates/manage/organization/publishing.html:395 +msgid "" +"Tip: Trusted publishers created here will be owned by " +"this organization when the project is created. Organization owners can " +"manage these publishers and their associated projects." +msgstr "" + +#: warehouse/templates/manage/organization/publishing.html:401 +msgid "Pending publishers" +msgstr "" + +#: warehouse/templates/manage/organization/publishing.html:403 +msgid "" +"Pending publishers are trusted publishers for projects that do not exist " +"yet. They can be used to create a new project on first upload." +msgstr "" + +#: warehouse/templates/manage/organization/publishing.html:411 +msgid "Pending publishers for this organization" +msgstr "" + +#: warehouse/templates/manage/organization/publishing.html:477 +msgid "You must be an organization owner to manage trusted publishers." +msgstr "" + #: warehouse/templates/manage/organization/roles.html:5 #, python-format msgid "Manage people in '%(organization_name)s'" @@ -6533,16 +6677,6 @@ msgid "" "before submitting the form." msgstr "" -#: warehouse/templates/manage/project/publishing.html:203 -msgid "GitLab instance" -msgstr "" - -#: warehouse/templates/manage/project/publishing.html:210 -msgid "" -"The GitLab instance URL. Select https://gitlab.com for the " -"public GitLab service, or a custom instance." -msgstr "" - #: warehouse/templates/manage/project/publishing.html:270 #, python-format msgid ""