Skip to content

Commit 36a5819

Browse files
authored
feat: look up custom issuers by inbound URL (#18892)
* feat: look up custom issuers by inbound URL When receiving an inbound JWT, handle cases where the inbound issuer is not one of the "standard" service URLs, such as in the case of GitLab Self-Managed or GitHub Enterprise Server instances. * feat: enable pending gitlab publisher reification --------- Signed-off-by: Mike Fiedler <miketheman@gmail.com>
1 parent 5d99266 commit 36a5819

File tree

7 files changed

+118
-10
lines changed

7 files changed

+118
-10
lines changed

tests/unit/oidc/models/test_gitlab.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,8 @@
3838
),
3939
("gitlab.com/foo/bar//a.yml@/some/ref", "a.yml"),
4040
("gitlab.com/foo/bar//a/b.yml@/some/ref", "a/b.yml"),
41+
# Custom domain.
42+
("gitlab.example.com/foo/bar//example.yml@/some/ref", "example.yml"),
4143
# Malformed `ci_config_ref_uri`s.
4244
("gitlab.com/foo/bar//notnested.wrongsuffix@/some/ref", None),
4345
("gitlab.com/foo/bar//@/some/ref", None),
@@ -59,6 +61,7 @@ class TestGitLabPublisher:
5961
@pytest.mark.parametrize("environment", [None, "some_environment"])
6062
def test_lookup_fails_invalid_ci_config_ref_uri(self, environment):
6163
signed_claims = {
64+
"iss": "https://gitlab.com",
6265
"project_path": "foo/bar",
6366
"ci_config_ref_uri": ("gitlab.com/foo/bar//example/.yml@refs/heads/main"),
6467
}
@@ -101,6 +104,7 @@ def test_lookup_succeeds_with_mixed_case_project_path(
101104
)
102105

103106
signed_claims = {
107+
"iss": "https://gitlab.com",
104108
"project_path": project_path,
105109
"ci_config_ref_uri": "gitlab.com/foo/bar//.gitlab-ci.yml@refs/heads/main",
106110
"environment": "some_environment",
@@ -129,6 +133,7 @@ def test_lookup_succeeds_with_non_lowercase_environment(
129133
)
130134

131135
signed_claims = {
136+
"iss": "https://gitlab.com",
132137
"project_path": "foo/bar",
133138
"ci_config_ref_uri": ("gitlab.com/foo/bar//.gitlab-ci.yml@refs/heads/main"),
134139
"environment": environment,
@@ -157,6 +162,7 @@ def test_lookup_is_case_sensitive_for_environment(self, db_request, environment)
157162
)
158163

159164
signed_claims = {
165+
"iss": "https://gitlab.com",
160166
"project_path": "foo/bar",
161167
"ci_config_ref_uri": ("gitlab.com/foo/bar//.gitlab-ci.yml@refs/heads/main"),
162168
"environment": environment,
@@ -194,6 +200,7 @@ def test_lookup_escapes(
194200

195201
for workflow_filepath in (workflow_filepath_a, workflow_filepath_b):
196202
signed_claims = {
203+
"iss": "https://gitlab.com",
197204
"project_path": "foo/bar",
198205
"ci_config_ref_uri": (
199206
f"gitlab.com/foo/bar//{workflow_filepath}@refs/heads/main"
@@ -212,6 +219,7 @@ def test_lookup_escapes(
212219

213220
def test_lookup_no_matching_publisher(self, db_request):
214221
signed_claims = {
222+
"iss": "https://gitlab.com",
215223
"project_path": "foo/bar",
216224
"ci_config_ref_uri": ("gitlab.com/foo/bar//.gitlab-ci.yml@refs/heads/main"),
217225
}
@@ -272,6 +280,7 @@ def test_gitlab_publisher_computed_properties(self):
272280
namespace="fakeowner",
273281
workflow_filepath="subfolder/fakeworkflow.yml",
274282
environment="fakeenv",
283+
issuer_url="https://gitlab.com",
275284
)
276285

277286
for claim_name in publisher.__required_verifiable_claims__.keys():
@@ -359,6 +368,7 @@ def test_gitlab_publisher_missing_claims(self, monkeypatch, missing):
359368
project="fakerepo",
360369
namespace="fakeowner",
361370
workflow_filepath="subfolder/fakeworkflow.yml",
371+
issuer_url="https://gitlab.com",
362372
)
363373

364374
scope = pretend.stub()
@@ -394,6 +404,7 @@ def test_gitlab_publisher_missing_optional_claims(self, monkeypatch):
394404
namespace="fakeowner",
395405
workflow_filepath="subfolder/fakeworkflow.yml",
396406
environment="some-environment", # The optional claim that should be present
407+
issuer_url="https://gitlab.com",
397408
)
398409

399410
sentry_sdk = pretend.stub(capture_message=pretend.call_recorder(lambda s: None))
@@ -429,6 +440,7 @@ def test_gitlab_publisher_verifies(self, monkeypatch, environment, missing_claim
429440
namespace="fakeowner",
430441
workflow_filepath="subfolder/fakeworkflow.yml",
431442
environment="environment",
443+
issuer_url="https://gitlab.com",
432444
)
433445

434446
noop_check = pretend.call_recorder(lambda gt, sc, ac, **kwargs: True)
@@ -661,6 +673,7 @@ def test_gitlab_publisher_ci_config_ref_uri(
661673
project="bar",
662674
namespace="foo",
663675
workflow_filepath="workflows/baz.yml",
676+
issuer_url="https://gitlab.com",
664677
)
665678

666679
check = gitlab.GitLabPublisher.__required_verifiable_claims__[
@@ -844,6 +857,7 @@ def test_gitlab_publisher_verify_url(
844857
namespace=namespace,
845858
workflow_filepath="workflow_filename.yml",
846859
environment="",
860+
issuer_url="https://gitlab.com",
847861
)
848862
assert publisher.verify_url(url) == expected
849863

@@ -854,6 +868,7 @@ def test_gitlab_publisher_attestation_identity(self, environment):
854868
namespace="group/subgroup",
855869
workflow_filepath="workflow_filename.yml",
856870
environment=environment,
871+
issuer_url="https://gitlab.com",
857872
)
858873

859874
identity = publisher.attestation_identity
@@ -922,3 +937,13 @@ def test_reify_already_exists(self, db_request):
922937
# it is returned and the pending publisher is marked for deletion.
923938
assert existing_publisher == publisher
924939
assert pending_publisher in db_request.db.deleted
940+
941+
def test_reify_with_custom_issuer_url(self, db_request):
942+
custom_issuer_url = "https://gitlab.custom-domain.com"
943+
pending_publisher = PendingGitLabPublisherFactory.create(
944+
issuer_url=custom_issuer_url
945+
)
946+
publisher = pending_publisher.reify(db_request.db)
947+
948+
assert publisher.issuer_url == custom_issuer_url
949+
assert isinstance(publisher, gitlab.GitLabPublisher)

tests/unit/oidc/test_utils.py

Lines changed: 48 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
GitLabPublisherFactory,
1414
GooglePublisherFactory,
1515
)
16+
from tests.common.db.organizations import OrganizationOIDCIssuerFactory
1617
from warehouse.oidc import errors, utils
1718
from warehouse.oidc.models import (
1819
ActiveStatePublisher,
@@ -21,13 +22,16 @@
2122
GooglePublisher,
2223
)
2324
from warehouse.oidc.utils import OIDC_PUBLISHER_CLASSES
25+
from warehouse.organizations.models import OIDCIssuerType
2426
from warehouse.utils.security_policy import principals_for
2527

2628

2729
def test_find_publisher_by_issuer_bad_issuer_url():
30+
session = pretend.stub(scalar=lambda *stmt: None)
31+
2832
with pytest.raises(errors.InvalidPublisherError):
2933
utils.find_publisher_by_issuer(
30-
pretend.stub(), "https://fake-issuer.url", pretend.stub()
34+
session, "https://fake-issuer.url", pretend.stub()
3135
)
3236

3337

@@ -140,6 +144,7 @@ def test_find_publisher_by_issuer_gitlab(db_request, environment, expected_id):
140144

141145
signed_claims.update(
142146
{
147+
"iss": utils.GITLAB_OIDC_ISSUER_URL,
143148
"project_path": "foo/bar",
144149
"ci_config_ref_uri": "gitlab.com/foo/bar//workflows/ci.yml@refs/heads/main",
145150
}
@@ -307,3 +312,45 @@ def test_oidc_maps_consistent():
307312
# The class mapping for pending and non-pending publisher models
308313
# should be distinct.
309314
assert class_map[True] != class_map[False]
315+
316+
317+
def test_find_publisher_by_issuer_with_custom_issuer(db_request):
318+
"""
319+
A custom OIDC issuer URL is properly resolved to a concrete publisher.
320+
"""
321+
# Create organization and register a custom GitLab issuer URL
322+
custom_issuer_url = "https://gitlab.custom-company.com"
323+
OrganizationOIDCIssuerFactory.create(
324+
issuer_type=OIDCIssuerType.GitLab,
325+
issuer_url=custom_issuer_url,
326+
)
327+
328+
# Create a GitLab publisher that would match the claims
329+
publisher = GitLabPublisherFactory(
330+
namespace="foo",
331+
project="bar",
332+
workflow_filepath="workflows/ci.yml",
333+
environment="",
334+
issuer_url=custom_issuer_url,
335+
)
336+
337+
# Create signed claims that would come from the custom GitLab instance
338+
signed_claims = {
339+
claim_name: "fake" for claim_name in GitLabPublisher.all_known_claims()
340+
}
341+
signed_claims.update(
342+
{
343+
"iss": custom_issuer_url,
344+
"project_path": "foo/bar",
345+
"ci_config_ref_uri": "gitlab.custom-company.com/foo/bar//workflows/ci.yml@refs/heads/main", # noqa: E501
346+
}
347+
)
348+
349+
# This should successfully resolve the custom issuer URL to the GitLab publisher
350+
result = utils.find_publisher_by_issuer(
351+
db_request.db,
352+
utils.GITLAB_OIDC_ISSUER_URL,
353+
signed_claims,
354+
)
355+
356+
assert result.id == publisher.id

tests/unit/oidc/test_views.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -215,11 +215,13 @@ def find_service(self, *a, **kw):
215215
assert err["description"] == "malformed JWT"
216216

217217

218-
def test_mint_token_from_oidc_unknown_issuer():
218+
def test_mint_token_from_oidc_unknown_issuer(metrics):
219219
class Request:
220220
def __init__(self):
221221
self.response = pretend.stub(status=None)
222222
self.flags = pretend.stub(disallow_oidc=lambda *a: False)
223+
self.db = pretend.stub(scalar=lambda *stmt: None)
224+
self.metrics = metrics
223225

224226
@property
225227
def body(self):

warehouse/oidc/models/gitlab.py

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -80,7 +80,7 @@ def _check_ci_config_ref_uri(
8080
**_kwargs,
8181
) -> bool:
8282
# We expect a string formatted as follows:
83-
# gitlab.com/OWNER/REPO//WORKFLOW_PATH/WORKFLOW_FILE.yml@REF
83+
# <gitlab.com>/OWNER/REPO//WORKFLOW_PATH/WORKFLOW_FILE.yml@REF
8484
# where REF is the value of the `ref_path` claim.
8585

8686
# Defensive: GitLab should never give us an empty ci_config_ref_uri,
@@ -234,6 +234,7 @@ def _get_publisher_for_environment(
234234

235235
@classmethod
236236
def lookup_by_claims(cls, session: Session, signed_claims: SignedClaims) -> Self:
237+
issuer_url = signed_claims["iss"]
237238
project_path = signed_claims["project_path"]
238239
ci_config_ref_uri = signed_claims["ci_config_ref_uri"]
239240
namespace, project = project_path.rsplit("/", 1)
@@ -249,6 +250,7 @@ def lookup_by_claims(cls, session: Session, signed_claims: SignedClaims) -> Self
249250
func.lower(cls.namespace) == func.lower(namespace),
250251
func.lower(cls.project) == func.lower(project),
251252
cls.workflow_filepath == workflow_filepath,
253+
cls.issuer_url == issuer_url,
252254
)
253255
publishers = query.with_session(session).all()
254256
if publisher := cls._get_publisher_for_environment(publishers, environment):
@@ -266,15 +268,18 @@ def sub(self) -> str:
266268

267269
@property
268270
def ci_config_ref_uri(self) -> str:
269-
return f"gitlab.com/{self.project_path}//{self.workflow_filepath}"
271+
# Extract domain from issuer_url (remove https:// prefix)
272+
domain = self.issuer_url.removeprefix("https://")
273+
return f"{domain}/{self.project_path}//{self.workflow_filepath}"
270274

271275
@property
272276
def publisher_name(self) -> str:
273277
return "GitLab"
274278

275279
@property
276280
def publisher_base_url(self) -> str:
277-
return f"https://gitlab.com/{self.project_path}"
281+
# Use the issuer_url which already includes the scheme (https://)
282+
return f"{self.issuer_url}/{self.project_path}"
278283

279284
@property
280285
def jti(self) -> str:
@@ -430,6 +435,7 @@ def reify(self, session: Session) -> GitLabPublisher:
430435
GitLabPublisher.project == self.project,
431436
GitLabPublisher.workflow_filepath == self.workflow_filepath,
432437
GitLabPublisher.environment == self.environment,
438+
GitLabPublisher.issuer_url == self.issuer_url,
433439
)
434440
.one_or_none()
435441
)
@@ -439,7 +445,7 @@ def reify(self, session: Session) -> GitLabPublisher:
439445
project=self.project,
440446
workflow_filepath=self.workflow_filepath,
441447
environment=self.environment,
442-
issuer_url=GITLAB_OIDC_ISSUER_URL,
448+
issuer_url=self.issuer_url,
443449
)
444450

445451
session.delete(self)

warehouse/oidc/services.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,7 @@ def verify_jwt_signature(
7777

7878
def find_publisher(
7979
self, signed_claims: SignedClaims, *, pending: bool = False
80-
) -> OIDCPublisher | PendingOIDCPublisher | None:
80+
) -> OIDCPublisher | PendingOIDCPublisher:
8181
# NOTE: We do NOT verify the claims against the publisher, since this
8282
# service is for development purposes only.
8383
return find_publisher_by_issuer(

warehouse/oidc/utils.py

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
from dataclasses import dataclass
88

99
from pyramid.authorization import Authenticated
10+
from sqlalchemy import select
1011

1112
from warehouse.admin.flags import AdminFlagValue
1213
from warehouse.oidc.errors import InvalidPublisherError
@@ -27,10 +28,13 @@
2728
PendingGooglePublisher,
2829
PendingOIDCPublisher,
2930
)
31+
from warehouse.organizations.models import OrganizationOIDCIssuer
3032

3133
if typing.TYPE_CHECKING:
3234
from sqlalchemy.orm import Session
3335

36+
from warehouse.organizations.models import OIDCIssuerType
37+
3438

3539
OIDC_ISSUER_SERVICE_NAMES = {
3640
GITHUB_OIDC_ISSUER_URL: "github",
@@ -59,6 +63,18 @@
5963
}
6064

6165

66+
def lookup_custom_issuer_type(
67+
session: Session, issuer_url: str
68+
) -> OIDCIssuerType | None:
69+
"""
70+
Look up the issuer type for an Organization's OIDC issuer URL.
71+
"""
72+
stmt = select(OrganizationOIDCIssuer.issuer_type).where(
73+
OrganizationOIDCIssuer.issuer_url == issuer_url
74+
)
75+
return session.scalar(stmt)
76+
77+
6278
def find_publisher_by_issuer(
6379
session: Session,
6480
issuer_url: str,
@@ -72,7 +88,7 @@ def find_publisher_by_issuer(
7288
to one or more projects or a `PendingOIDCPublisher`, varying with the
7389
`pending` parameter.
7490
75-
Returns `None` if no publisher can be found.
91+
Raises if no publisher can be found.
7692
"""
7793

7894
try:

warehouse/oidc/views.py

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,11 @@
2424
from warehouse.oidc.models import GitHubPublisher, OIDCPublisher, PendingOIDCPublisher
2525
from warehouse.oidc.models.gitlab import GitLabPublisher
2626
from warehouse.oidc.services import OIDCPublisherService
27-
from warehouse.oidc.utils import OIDC_ISSUER_ADMIN_FLAGS, OIDC_ISSUER_SERVICE_NAMES
27+
from warehouse.oidc.utils import (
28+
OIDC_ISSUER_ADMIN_FLAGS,
29+
OIDC_ISSUER_SERVICE_NAMES,
30+
lookup_custom_issuer_type,
31+
)
2832
from warehouse.packaging.interfaces import IProjectService
2933
from warehouse.packaging.models import ProjectFactory
3034
from warehouse.rate_limiting.interfaces import IRateLimiter
@@ -135,8 +139,16 @@ def mint_token_from_oidc(request: Request):
135139
)
136140

137141
# Associate the given issuer claim with Warehouse's OIDCPublisherService.
142+
# First, try the standard issuers
138143
service_name = OIDC_ISSUER_SERVICE_NAMES.get(unverified_issuer)
144+
# If not in global mapping, check for organization-specific custom issuer
145+
if not service_name:
146+
service_name = lookup_custom_issuer_type(request.db, unverified_issuer)
139147
if not service_name:
148+
request.metrics.increment(
149+
"warehouse.oidc.mint_token_from_oidc.unknown_issuer",
150+
tags={"issuer_url": unverified_issuer},
151+
)
140152
return _invalid(
141153
errors=[
142154
{
@@ -147,7 +159,7 @@ def mint_token_from_oidc(request: Request):
147159
request=request,
148160
)
149161

150-
if request.flags.disallow_oidc(OIDC_ISSUER_ADMIN_FLAGS[unverified_issuer]):
162+
if request.flags.disallow_oidc(OIDC_ISSUER_ADMIN_FLAGS.get(unverified_issuer)):
151163
return _invalid(
152164
errors=[
153165
{

0 commit comments

Comments
 (0)