Skip to content

Buildkite OIDC support #14814

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 3 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
154 changes: 152 additions & 2 deletions warehouse/accounts/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -77,9 +77,14 @@
from warehouse.events.tags import EventTag
from warehouse.metrics.interfaces import IMetricsService
from warehouse.oidc.forms import DeletePublisherForm
from warehouse.oidc.forms.buildkite import PendingBuildkitePublisherForm
from warehouse.oidc.forms.github import PendingGitHubPublisherForm
from warehouse.oidc.interfaces import TooManyOIDCRegistrations
from warehouse.oidc.models import PendingGitHubPublisher, PendingOIDCPublisher
from warehouse.oidc.models import (
PendingBuildkitePublisher,
PendingGitHubPublisher,
PendingOIDCPublisher,
)
from warehouse.organizations.interfaces import IOrganizationService
from warehouse.organizations.models import OrganizationRole, OrganizationRoleType
from warehouse.packaging.models import (
Expand Down Expand Up @@ -1488,6 +1493,13 @@ def _check_ratelimits(self):
)
)

@property
def pending_buildkite_publisher_form(self):
return PendingBuildkitePublisherForm(
self.request.POST,
project_factory=self.project_factory
)

@property
def pending_github_publisher_form(self):
return PendingGitHubPublisherForm(
Expand All @@ -1499,6 +1511,8 @@ def pending_github_publisher_form(self):
@property
def default_response(self):
return {
"default_publisher_name": "github",
"pending_buildkite_publisher_form": self.pending_buildkite_publisher_form,
"pending_github_publisher_form": self.pending_github_publisher_form,
}

Expand All @@ -1516,11 +1530,147 @@ def manage_publishing(self):

return self.default_response

@view_config(
route_name="manage.account.publishing.buildkite",
request_method="POST",
request_param=PendingBuildkitePublisherForm.__params__,
)
def add_pending_buildkite_oidc_publisher(self):
response = self.default_response
response["default_publisher_name"] = "buildkite"

if self.request.flags.disallow_oidc(AdminFlagValue.DISALLOW_BUILDKITE_OIDC):
self.request.session.flash(
self.request._(
"Buildkite-based trusted publishing is temporarily disabled. "
"See https://pypi.org/help#admin-intervention for details."
),
queue="error",
)
return response

self.metrics.increment(
"warehouse.oidc.add_pending_publisher.attempt", tags=["publisher:Buildkite"]
)

if not self.request.user.has_primary_verified_email:
self.request.session.flash(
self.request._(
"You must have a verified email in order to register a "
"pending trusted publisher. "
"See https://pypi.org/help#openid-connect for details."
),
queue="error",
)
return response

# Separately from having permission to register pending OIDC publishers,
# we limit users to no more than 3 pending publishers at once.
if len(self.request.user.pending_oidc_publishers) >= 3:
self.request.session.flash(
self.request._(
"You can't register more than 3 pending trusted "
"publishers at once."
),
queue="error",
)
return response

try:
self._check_ratelimits()
except TooManyOIDCRegistrations as exc:
self.metrics.increment(
"warehouse.oidc.add_pending_publisher.ratelimited",
tags=["publisher:Buildkite"],
)
return HTTPTooManyRequests(
self.request._(
"There have been too many attempted trusted publisher "
"registrations. Try again later."
),
retry_after=exc.resets_in.total_seconds(),
)

self._hit_ratelimits()

response = response
form = response["pending_buildkite_publisher_form"]

if not form.validate():
self.request.session.flash(
self.request._("The trusted publisher could not be registered"),
queue="error",
)
return response

publisher_already_exists = (
self.request.db.query(PendingBuildkitePublisher)
.filter_by(
organization_slug=form.organization_slug.data,
pipeline_slug=form.pipeline_slug.data,
)
.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 response

pending_publisher = PendingBuildkitePublisher(
project_name=form.project_name.data,
added_by=self.request.user,
organization_slug=form.organization_slug.data,
pipeline_slug=form.pipeline_slug.data,
build_branch=form.build_branch.data,
build_tag=form.build_tag.data,
step_key=form.step_key.data,
)

self.request.db.add(pending_publisher)
self.request.db.flush() # To get the new ID

self.request.user.record_event(
tag=EventTag.Account.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}'."
),
queue="success",
)

self.metrics.increment(
"warehouse.oidc.add_pending_publisher.ok", tags=["publisher:Buildkite"]
)

return HTTPSeeOther(self.request.route_path("manage.account.publishing"))

@view_config(
request_method="POST",
request_param=PendingGitHubPublisherForm.__params__,
)
def add_pending_github_oidc_publisher(self):
response = self.default_response
response["default_publisher_name"] = "github"

if self.request.flags.disallow_oidc(AdminFlagValue.DISALLOW_GITHUB_OIDC):
self.request.session.flash(
self.request._(
Expand All @@ -1529,7 +1679,7 @@ def add_pending_github_oidc_publisher(self):
),
queue="error",
)
return self.default_response
return response

self.metrics.increment(
"warehouse.oidc.add_pending_publisher.attempt", tags=["publisher:GitHub"]
Expand Down
1 change: 1 addition & 0 deletions warehouse/admin/flags.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ class AdminFlagValue(enum.Enum):
DISALLOW_NEW_UPLOAD = "disallow-new-upload"
DISALLOW_NEW_USER_REGISTRATION = "disallow-new-user-registration"
DISALLOW_OIDC = "disallow-oidc"
DISALLOW_BUILDKITE_OIDC = "disallow-buildkite-oidc"
DISALLOW_GITHUB_OIDC = "disallow-github-oidc"
DISALLOW_GOOGLE_OIDC = "disallow-google-oidc"
READ_ONLY = "read-only"
Expand Down
141 changes: 138 additions & 3 deletions warehouse/manage/views/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -101,9 +101,14 @@
)
from warehouse.metrics.interfaces import IMetricsService
from warehouse.oidc.forms import DeletePublisherForm
from warehouse.oidc.forms.buildkite import BuildkitePublisherForm
from warehouse.oidc.forms.github import GitHubPublisherForm
from warehouse.oidc.interfaces import TooManyOIDCRegistrations
from warehouse.oidc.models import GitHubPublisher, OIDCPublisher
from warehouse.oidc.models import (
BuildkitePublisher,
GitHubPublisher,
OIDCPublisher,
)
from warehouse.organizations.interfaces import IOrganizationService
from warehouse.organizations.models import (
OrganizationProject,
Expand Down Expand Up @@ -1216,6 +1221,12 @@ def _check_ratelimits(self):
)
)

@property
def buildkite_publisher_form(self):
return BuildkitePublisherForm(
self.request.POST,
)

@property
def github_publisher_form(self):
return GitHubPublisherForm(
Expand All @@ -1227,6 +1238,8 @@ def github_publisher_form(self):
def default_response(self):
return {
"project": self.project,
"default_publisher_name": "github",
"buildkite_publisher_form": self.buildkite_publisher_form,
"github_publisher_form": self.github_publisher_form,
}

Expand All @@ -1243,11 +1256,133 @@ def manage_project_oidc_publishers(self):

return self.default_response

@view_config(
route_name="manage.project.settings.publishing.buildkite",
request_method="POST",
request_param=BuildkitePublisherForm.__params__,
)
def add_buildkite_oidc_publisher(self):
response = self.default_response
response["default_publisher_name"] = "buildkite"

if self.request.flags.disallow_oidc(AdminFlagValue.DISALLOW_BUILDKITE_OIDC):
self.request.session.flash(
self.request._(
"Buildkite-based trusted publishing is temporarily disabled. "
"See https://pypi.org/help#admin-intervention for details."
),
queue="error",
)
return response

self.metrics.increment(
"warehouse.oidc.add_publisher.attempt", tags=["publisher:Buildkite"]
)

try:
self._check_ratelimits()
except TooManyOIDCRegistrations as exc:
self.metrics.increment(
"warehouse.oidc.add_publisher.ratelimited", tags=["publisher:Buildkite"]
)
return HTTPTooManyRequests(
self.request._(
"There have been too many attempted trusted publisher "
"registrations. Try again later."
),
retry_after=exc.resets_in.total_seconds(),
)

self._hit_ratelimits()

form = response["buildkite_publisher_form"]

if not form.validate():
self.request.session.flash(
self.request._("The trusted publisher could not be registered"),
queue="error",
)
return response

# Buildkite OIDC publishers are unique on the tuple of
# (repository_name, repository_owner, workflow_filename, environment),
# so we check for an already registered one before creating.
publisher = (
self.request.db.query(BuildkitePublisher)
.filter(
BuildkitePublisher.organization_slug == form.organization_slug.data,
BuildkitePublisher.pipeline_slug == form.pipeline_slug.data,
)
.one_or_none()
)
if publisher is None:
publisher = BuildkitePublisher(
organization_slug=form.organization_slug.data,
pipeline_slug=form.pipeline_slug.data,
build_branch=form.build_branch.data,
build_tag=form.build_tag.data,
step_key=form.step_key.data,
)

self.request.db.add(publisher)

# Each project has a unique set of OIDC publishers; the same
# publisher can't be registered to the project more than once.
if publisher in self.project.oidc_publishers:
self.request.session.flash(
self.request._(
f"{publisher} is already registered with {self.project.name}"
),
queue="error",
)
return response

for user in self.project.users:
send_trusted_publisher_added_email(
self.request,
user,
project_name=self.project.name,
publisher=publisher,
)

self.project.oidc_publishers.append(publisher)

self.project.record_event(
tag=EventTag.Project.OIDCPublisherAdded,
request=self.request,
additional={
"publisher": publisher.publisher_name,
"id": str(publisher.id),
"specifier": str(publisher),
"url": publisher.publisher_url(),
"submitted_by": self.request.user.username,
},
)

self.request.session.flash(
f"Added {publisher} in {publisher.publisher_url()} to {self.project.name}",
queue="success",
)

self.metrics.increment(
"warehouse.oidc.add_publisher.ok", tags=["publisher:Buildkite"]
)

return HTTPSeeOther(
self.request.route_path(
"manage.project.settings.publishing",
project_name=self.project.name,
)
)

@view_config(
request_method="POST",
request_param=GitHubPublisherForm.__params__,
)
def add_github_oidc_publisher(self):
response = self.default_response
response["default_publisher_name"] = "github"

if self.request.flags.disallow_oidc(AdminFlagValue.DISALLOW_GITHUB_OIDC):
self.request.session.flash(
self.request._(
Expand All @@ -1256,7 +1391,7 @@ def add_github_oidc_publisher(self):
),
queue="error",
)
return self.default_response
return response

self.metrics.increment(
"warehouse.oidc.add_publisher.attempt", tags=["publisher:GitHub"]
Expand All @@ -1278,7 +1413,7 @@ def add_github_oidc_publisher(self):

self._hit_ratelimits()

response = self.default_response
response = response
form = response["github_publisher_form"]

if not form.validate():
Expand Down
Loading