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) }}
+
+{% 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) }}
+
+{% 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) }}
+
+{% 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) }}
+
+{% 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 %}
+
+ {% trans %}Pending publishers for this organization{% endtrans %}
+
+
+ {% trans %}Pending project name{% endtrans %}
+ {% trans %}Publisher{% endtrans %}
+ {% trans %}Details{% endtrans %}
+
+
+
+
+ {% for publisher in pending_oidc_publishers %}{{ oidc_publisher_row(publisher) }}{% endfor %}
+
+
+ {% 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] %}
+ {{ 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 ""