diff --git a/tests/common/db/oidc.py b/tests/common/db/oidc.py index 121e0130b85f..59b71311a106 100644 --- a/tests/common/db/oidc.py +++ b/tests/common/db/oidc.py @@ -13,8 +13,10 @@ import factory from warehouse.oidc.models import ( + ActiveStatePublisher, GitHubPublisher, GooglePublisher, + PendingActiveStatePublisher, PendingGitHubPublisher, PendingGooglePublisher, ) @@ -67,3 +69,29 @@ class Meta: email = factory.Faker("safe_email") sub = factory.Faker("pystr", max_chars=12) added_by = factory.SubFactory(UserFactory) + + +class ActiveStatePublisherFactory(WarehouseFactory): + class Meta: + model = ActiveStatePublisher + + id = factory.Faker("uuid4", cast_to=None) + organization = factory.Faker("pystr", max_chars=12) + activestate_project_name = factory.Faker("pystr", max_chars=12) + actor = factory.Faker("pystr", max_chars=12) + actor_id = factory.Faker("uuid4") + ingredient = factory.Faker("pystr", max_chars=12) + + +class PendingActiveStatePublisherFactory(WarehouseFactory): + class Meta: + model = PendingActiveStatePublisher + + id = factory.Faker("uuid4", cast_to=None) + project_name = factory.Faker("pystr", max_chars=12) + organization = factory.Faker("pystr", max_chars=12) + activestate_project_name = factory.Faker("pystr", max_chars=12) + actor = factory.Faker("pystr", max_chars=12) + actor_id = factory.Faker("uuid4") + added_by = factory.SubFactory(UserFactory) + ingredient = factory.Faker("pystr", max_chars=12) diff --git a/tests/unit/oidc/models/test_activestate.py b/tests/unit/oidc/models/test_activestate.py new file mode 100644 index 000000000000..31d59e65bed0 --- /dev/null +++ b/tests/unit/oidc/models/test_activestate.py @@ -0,0 +1,374 @@ +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + + +import pretend +import pytest + +from tests.common.db.oidc import ( + ActiveStatePublisherFactory, + PendingActiveStatePublisherFactory, +) +from warehouse.oidc.errors import InvalidPublisherError +from warehouse.oidc.interfaces import SignedClaims +from warehouse.oidc.models import _core +from warehouse.oidc.models.activestate import ( + ActiveStatePublisher, + PendingActiveStatePublisher, +) + +ORG_URL_NAME = "fakeorg" +PROJECT_NAME = "fakeproject" +ACTOR_ID = "00000000-0000-1000-8000-000000000002" +ACTOR = "fakeuser" +INGREDIENT = "fakeingredientname" +# This follows the format of the subject that ActiveState sends us. We don't +# validate the format when verifying the JWT. That should happen when the +# Publisher is configured. We just need to make sure that the subject matches +SUBJECT = f"org:{ORG_URL_NAME}:project:{PROJECT_NAME}" + + +def test_lookup_strategies(): + assert ( + len(ActiveStatePublisher.__lookup_strategies__) + == len(PendingActiveStatePublisher.__lookup_strategies__) + == 1 + ) + + +def new_signed_claims( + sub: str = SUBJECT, + actor: str = ACTOR, + actor_id: str = ACTOR_ID, + ingredient: str = INGREDIENT, + organization: str = ORG_URL_NAME, + org_id: str = "fakeorgid", + project: str = PROJECT_NAME, + project_id: str = "fakeprojectid", + project_path: str = "fakeorg/fakeproject", + project_visibility: str = "public", + branch_id: str | None = None, +) -> SignedClaims: + claims = SignedClaims( + { + "sub": sub, + "actor": actor, + "actor_id": actor_id, + "ingredient": ingredient, + "organization_id": org_id, + "organization": organization, + "project_visibility": project_visibility, + "project_id": project_id, + "project_path": project_path, + "project": project, + "builder": "pypi-publisher", + } + ) + if branch_id: + claims["branch_id"] = branch_id + return claims + + +class TestActiveStatePublisher: + def test_publisher_name(self): + publisher = ActiveStatePublisher() + + assert publisher.publisher_name == "ActiveState" + + def test_publisher_url(self): + org_name = "fakeorg" + project_name = "fakeproject" + publisher = ActiveStatePublisher( + organization=org_name, activestate_project_name=project_name + ) + + assert ( + publisher.publisher_url() + == f"https://platform.activestate.com/{org_name}/{project_name}" + ) + + def test_stringifies_as_project_url(self): + org_name = "fakeorg" + project_name = "fakeproject" + publisher = ActiveStatePublisher( + organization=org_name, activestate_project_name=project_name + ) + + assert ( + str(publisher) + == f"https://platform.activestate.com/{org_name}/{project_name}" + ) + + def test_activestate_publisher_all_known_claims(self): + assert ActiveStatePublisher.all_known_claims() == { + # verifiable claims + "organization", + "project", + "actor_id", + "actor", + "builder", + "sub", + "artifact_id", + # preverified claims + "iss", + "iat", + "nbf", + "exp", + "aud", + # unchecked claims + "project_visibility", + "project_path", + "ingredient", + "organization_id", + "project_id", + } + + def test_activestate_publisher_unaccounted_claims(self, monkeypatch): + publisher = ActiveStatePublisher( + organization=ORG_URL_NAME, + activestate_project_name=PROJECT_NAME, + actor_id=ACTOR_ID, + ingredient=INGREDIENT, + ) + + scope = pretend.stub() + sentry_sdk = pretend.stub( + capture_message=pretend.call_recorder(lambda s: None), + push_scope=pretend.call_recorder( + lambda: pretend.stub( + __enter__=lambda *a: scope, __exit__=lambda *a: None + ) + ), + ) + monkeypatch.setattr(_core, "sentry_sdk", sentry_sdk) + + signed_claims = new_signed_claims() + signed_claims["fake-claim"] = "fake" + signed_claims["another-fake-claim"] = "also-fake" + + assert publisher.verify_claims(signed_claims=signed_claims) + + assert sentry_sdk.capture_message.calls == [ + pretend.call( + "JWT for ActiveStatePublisher has unaccounted claims: " + "['another-fake-claim', 'fake-claim']" + ) + ] + assert scope.fingerprint == ["another-fake-claim", "fake-claim"] + + @pytest.mark.parametrize( + ("claim_to_drop", "valid", "error_msg"), + [ + ("organization", False, "Missing claim 'organization'"), + ("project", False, "Missing claim 'project'"), + ("actor_id", False, "Missing claim 'actor_id'"), + ("actor", True, None), + ("builder", False, "Missing claim 'builder'"), + ("ingredient", True, None), + ("organization_id", True, None), + ("project_id", True, None), + ("project_visibility", True, None), + ("project_path", True, None), + ], + ) + def test_activestate_publisher_missing_claims( + self, monkeypatch, claim_to_drop: str, valid: bool, error_msg: str | None + ): + publisher = ActiveStatePublisher( + organization=ORG_URL_NAME, + activestate_project_name=PROJECT_NAME, + actor_id=ACTOR_ID, + actor=ACTOR, + ingredient=INGREDIENT, + ) + + scope = pretend.stub() + sentry_sdk = pretend.stub( + capture_message=pretend.call_recorder(lambda s: None), + push_scope=pretend.call_recorder( + lambda: pretend.stub( + __enter__=lambda *a: scope, __exit__=lambda *a: None + ) + ), + ) + monkeypatch.setattr(_core, "sentry_sdk", sentry_sdk) + + signed_claims = new_signed_claims() + signed_claims.pop(claim_to_drop) + + assert claim_to_drop not in signed_claims + if valid: + assert publisher.verify_claims(signed_claims=signed_claims) is valid + else: + with pytest.raises(InvalidPublisherError) as e: + assert publisher.verify_claims(signed_claims=signed_claims) + assert str(e.value) == error_msg + assert sentry_sdk.capture_message.calls == [ + pretend.call( + "JWT for ActiveStatePublisher is missing claim: " + claim_to_drop + ) + ] + assert scope.fingerprint == [claim_to_drop] + + @pytest.mark.parametrize( + ("expect", "actual", "valid"), + [ + (ORG_URL_NAME, ORG_URL_NAME, True), + (ORG_URL_NAME, PROJECT_NAME, False), + ], + ) + def test_activestate_publisher_org_id_verified( + self, expect: str, actual: str, valid: bool + ): + publisher = ActiveStatePublisher( + organization=actual, + activestate_project_name=PROJECT_NAME, + actor_id=ACTOR_ID, + actor=ACTOR, + ingredient=INGREDIENT, + ) + + signed_claims = new_signed_claims(organization=expect) + check = publisher.__required_verifiable_claims__["organization"] + assert check(actual, expect, signed_claims) is valid + + @pytest.mark.parametrize( + ("expect", "actual", "valid"), + [ + (PROJECT_NAME, PROJECT_NAME, True), + (PROJECT_NAME, ORG_URL_NAME, False), + ], + ) + def test_activestate_publisher_project_id_verified( + self, expect: str, actual: str, valid: bool + ): + publisher = ActiveStatePublisher( + organization=ORG_URL_NAME, + activestate_project_name=actual, + actor_id=ACTOR_ID, + actor=ACTOR, + ingredient=INGREDIENT, + ) + + signed_claims = new_signed_claims(project=expect) + check = publisher.__required_verifiable_claims__["project"] + assert check(actual, expect, signed_claims) is valid + + @pytest.mark.parametrize( + ("expect", "actual", "valid"), + [ + (ACTOR_ID, ACTOR_ID, True), + (ACTOR_ID, ORG_URL_NAME, False), + ], + ) + def test_activestate_publisher_user_id_verified( + self, expect: str, actual: str, valid: bool + ): + publisher = ActiveStatePublisher( + organization=ORG_URL_NAME, + activestate_project_name=PROJECT_NAME, + actor_id=actual, + actor=ACTOR, + ingredient=INGREDIENT, + ) + signed_claims = new_signed_claims(actor_id=expect) + check = publisher.__required_verifiable_claims__["actor_id"] + assert check(actual, expect, signed_claims) is valid + + @pytest.mark.parametrize( + ("expected", "actual", "valid", "error_msg"), + [ + # Both present: must match. + ( + f"org:{ORG_URL_NAME}:project:{PROJECT_NAME}", + f"org:{ORG_URL_NAME}:project:{PROJECT_NAME}", + True, + None, + ), + # Both present: must match. + ( + f"org:{ORG_URL_NAME}:project:{PROJECT_NAME}", + "", + False, + "Missing 'subject' claim", + ), + # Wrong value, project, must fail. + ( + f"org:{ORG_URL_NAME}:project:{PROJECT_NAME}", + f"org:{ORG_URL_NAME}:project:{ORG_URL_NAME}", + False, + "Invalid 'subject' claim", + ), + # Wrong value, org_id, must fail. + ( + f"org:{ORG_URL_NAME}:project:{PROJECT_NAME}", + f"org:{PROJECT_NAME}:project:{PROJECT_NAME}", + False, + "Invalid 'subject' claim", + ), + # Just nonsenes, must fail. + ( + f"org:{ORG_URL_NAME}:project:{PROJECT_NAME}", + "Nonsense", + False, + "Invalid 'subject' claim. Wrong format", + ), + ], + ) + def test_activestate_publisher_sub( + self, expected: str, actual: str, valid: bool, error_msg: str | None + ): + check = ActiveStatePublisher.__required_verifiable_claims__["sub"] + signed_claims = new_signed_claims(sub=actual) + if valid: + assert check(expected, actual, signed_claims) is True + else: + with pytest.raises(InvalidPublisherError) as e: + check(expected, actual, signed_claims) + assert str(e.value) == error_msg + + +class TestPendingActiveStatePublisher: + def test_reify_does_not_exist_yet(self, db_request): + pending_publisher: PendingActiveStatePublisher = ( + PendingActiveStatePublisherFactory.create() + ) + assert ( + db_request.db.query(ActiveStatePublisher) + .filter_by( + organization=pending_publisher.organization, + activestate_project_name=pending_publisher.activestate_project_name, + actor_id=pending_publisher.actor_id, + actor=pending_publisher.actor, + ) + .one_or_none() + is None + ) + publisher = pending_publisher.reify(db_request.db) + + assert isinstance(publisher, ActiveStatePublisher) + assert pending_publisher in db_request.db.deleted + assert publisher.organization == pending_publisher.organization + assert publisher.sub == pending_publisher.sub + + def test_reify_already_exists(self, db_request): + existing_publisher: ActiveStatePublisher = ActiveStatePublisherFactory.create() + pending_publisher = PendingActiveStatePublisherFactory.create( + organization=existing_publisher.organization, + activestate_project_name=existing_publisher.activestate_project_name, + actor_id=existing_publisher.actor_id, + actor=existing_publisher.actor, + ) + publisher = pending_publisher.reify(db_request.db) + + assert existing_publisher == publisher + assert pending_publisher in db_request.db.deleted diff --git a/tests/unit/oidc/test_utils.py b/tests/unit/oidc/test_utils.py index 8b8bc8af3e52..38b2ce41a847 100644 --- a/tests/unit/oidc/test_utils.py +++ b/tests/unit/oidc/test_utils.py @@ -17,7 +17,11 @@ from pyramid.authorization import Authenticated -from tests.common.db.oidc import GitHubPublisherFactory, GooglePublisherFactory +from tests.common.db.oidc import ( + ActiveStatePublisherFactory, + GitHubPublisherFactory, + GooglePublisherFactory, +) from warehouse.oidc import errors, utils from warehouse.utils.security_policy import principals_for @@ -108,6 +112,84 @@ def test_find_publisher_by_issuer_google(db_request, sub, expected_id): ) +@pytest.mark.parametrize( + "expected_id, sub, organization, project, actor_id, actor", + [ + ( + uuid.UUID("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"), + "org:fakeorg1:project:fakeproject1", + "fakeorg1", + "fakeproject1", + "00000000-1000-8000-0000-000000000003", + "fakeuser1", + ), + ( + uuid.UUID("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"), + "org:fakeorg2:project:fakeproject2", + "fakeorg2", + "fakeproject2", + "00000000-1000-8000-0000-000000000006", + "fakeuser2", + ), + ( + uuid.UUID("cccccccc-cccc-cccc-cccc-cccccccccccc"), + "org:fakeorg3:project:fakeproject3", + "fakeorg3", + "fakeproject3", + "00000000-1000-8000-0000-000000000009", + "fakeuser3", + ), + ], +) +def test_find_publisher_by_issuer_activestate( + db_request, + sub: str, + expected_id: str, + organization: str, + project: str, + actor_id: str, + actor: str, +): + ActiveStatePublisherFactory( + id="aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", + organization="fakeorg1", + activestate_project_name="fakeproject1", + actor_id="00000000-1000-8000-0000-000000000003", + actor="fakeuser1", + ) + ActiveStatePublisherFactory( + id="bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb", + organization="fakeorg2", + activestate_project_name="fakeproject2", + actor_id="00000000-1000-8000-0000-000000000006", + actor="fakeuser2", + ) + ActiveStatePublisherFactory( + id="cccccccc-cccc-cccc-cccc-cccccccccccc", + organization="fakeorg3", + activestate_project_name="fakeproject3", + actor_id="00000000-1000-8000-0000-000000000009", + actor="fakeuser3", + ) + + signed_claims = { + "sub": sub, + "organization": organization, + "project": project, + "actor_id": actor_id, + "actor": actor, + } + + assert ( + utils.find_publisher_by_issuer( + db_request.db, + utils.ACTIVESTATE_OIDC_ISSUER_URL, + signed_claims, + ).id + == expected_id + ) + + def test_oidc_context_principals(): assert principals_for( utils.OIDCContext(publisher=pretend.stub(id=17), claims=None) diff --git a/warehouse/admin/flags.py b/warehouse/admin/flags.py index 17e39fb2e600..d2b23bf7a5ef 100644 --- a/warehouse/admin/flags.py +++ b/warehouse/admin/flags.py @@ -27,6 +27,7 @@ class AdminFlagValue(enum.Enum): DISALLOW_OIDC = "disallow-oidc" DISALLOW_GITHUB_OIDC = "disallow-github-oidc" DISALLOW_GOOGLE_OIDC = "disallow-google-oidc" + DISALLOW_ACTIVESTATE_OIDC = "disallow-activestate-oidc" READ_ONLY = "read-only" diff --git a/warehouse/migrations/versions/9a0ed2044b53_add_activestate_oidc_publisher.py b/warehouse/migrations/versions/9a0ed2044b53_add_activestate_oidc_publisher.py new file mode 100644 index 000000000000..a9b026cddbe7 --- /dev/null +++ b/warehouse/migrations/versions/9a0ed2044b53_add_activestate_oidc_publisher.py @@ -0,0 +1,86 @@ +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +""" +Add ActiveState OIDC publisher + +Revision ID: 9a0ed2044b53 +Revises: e6a1cca38664 +Create Date: 2023-11-30 00:05:52.057223 +""" + +import sqlalchemy as sa + +from alembic import op + +revision = "9a0ed2044b53" +down_revision = "e6a1cca38664" + + +def upgrade(): + op.create_table( + "activestate_oidc_publishers", + sa.Column("id", sa.UUID(), nullable=False), + sa.Column("organization", sa.String(), nullable=False), + sa.Column("activestate_project_name", sa.String(), nullable=False), + sa.Column("actor", sa.String(), nullable=False), + sa.Column("actor_id", sa.String(), nullable=False), + sa.Column("ingredient", sa.String(), nullable=True), + sa.ForeignKeyConstraint( + ["id"], + ["oidc_publishers.id"], + ), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint( + "organization", + "activestate_project_name", + "actor_id", + name="_activestate_oidc_publisher_uc", + ), + ) + op.create_table( + "pending_activestate_oidc_publishers", + sa.Column("id", sa.UUID(), nullable=False), + sa.Column("organization", sa.String(), nullable=False), + sa.Column("activestate_project_name", sa.String(), nullable=False), + sa.Column("actor", sa.String(), nullable=False), + sa.Column("actor_id", sa.String(), nullable=False), + sa.Column("ingredient", sa.String(), nullable=True), + sa.ForeignKeyConstraint( + ["id"], + ["pending_oidc_publishers.id"], + ), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint( + "organization", + "activestate_project_name", + "actor_id", + name="_pending_activestate_oidc_publisher_uc", + ), + ) + # Disable the ActiveState OIDC provider by default + op.execute( + """ + INSERT INTO admin_flags(id, description, enabled, notify) + VALUES ( + 'disallow-activestate-oidc', + 'Disallow the ActiveState OIDC provider', + TRUE, + FALSE + ) + """ + ) + + +def downgrade(): + op.drop_table("pending_activestate_oidc_publishers") + op.drop_table("activestate_oidc_publishers") + op.execute("DELETE FROM admin_flags WHERE id = 'disallow-activestate-oidc'") diff --git a/warehouse/oidc/__init__.py b/warehouse/oidc/__init__.py index acc629ed1471..eb979ca2e95b 100644 --- a/warehouse/oidc/__init__.py +++ b/warehouse/oidc/__init__.py @@ -42,6 +42,16 @@ def includeme(config): name="google", ) + config.register_service_factory( + OIDCPublisherServiceFactory( + publisher="activestate", + issuer_url=GOOGLE_OIDC_ISSUER_URL, + service_class=oidc_publisher_service_class, + ), + IOIDCPublisherService, + name="activestate", + ) + # During deployments, we separate auth routes into their own subdomain # to simplify caching exclusion. auth = config.get_settings().get("auth.domain") diff --git a/warehouse/oidc/models/__init__.py b/warehouse/oidc/models/__init__.py index 3d5d8bd33158..8417b6fd8ea8 100644 --- a/warehouse/oidc/models/__init__.py +++ b/warehouse/oidc/models/__init__.py @@ -11,6 +11,10 @@ # limitations under the License. from warehouse.oidc.models._core import OIDCPublisher, PendingOIDCPublisher +from warehouse.oidc.models.activestate import ( + ActiveStatePublisher, + PendingActiveStatePublisher, +) from warehouse.oidc.models.github import GitHubPublisher, PendingGitHubPublisher from warehouse.oidc.models.google import GooglePublisher, PendingGooglePublisher @@ -19,6 +23,8 @@ "PendingOIDCPublisher", "PendingGitHubPublisher", "PendingGooglePublisher", + "PendingActiveStatePublisher", "GitHubPublisher", "GooglePublisher", + "ActiveStatePublisher", ] diff --git a/warehouse/oidc/models/activestate.py b/warehouse/oidc/models/activestate.py new file mode 100644 index 000000000000..999b307d2dcd --- /dev/null +++ b/warehouse/oidc/models/activestate.py @@ -0,0 +1,196 @@ +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + + +import urllib + +from typing import Any + +from sqlalchemy import ForeignKey, String, UniqueConstraint +from sqlalchemy.dialects.postgresql import UUID +from sqlalchemy.orm import Query, mapped_column + +import warehouse.oidc.models._core as oidccore + +from warehouse.oidc.errors import InvalidPublisherError +from warehouse.oidc.interfaces import SignedClaims +from warehouse.oidc.models._core import ( + CheckClaimCallable, + OIDCPublisher, + PendingOIDCPublisher, +) + +_ACTIVESTATE_URL = "https://platform.activestate.com" + + +def _check_sub( + ground_truth: str, signed_claim: str, _all_signed_claims: SignedClaims +) -> bool: + # We expect a string formatted as follows: + # org::project: + + # Defensive: ActiveState should never give us an empty subject. + if not signed_claim: + raise InvalidPublisherError("Missing 'subject' claim") + + components = signed_claim.split(":") + + if len(components) < 4: + raise InvalidPublisherError("Invalid 'subject' claim. Wrong format") + + matches = ( + f"{components[0]}:{components[1]}:{components[2]}:{components[3]}" + == ground_truth + ) + if not matches: + raise InvalidPublisherError("Invalid 'subject' claim") + + return True + + +class ActiveStatePublisherMixin: + """ + Common functionality for both pending and concrete ActiveState OIDC publishers. + """ + + organization = mapped_column(String, nullable=False) + activestate_project_name = mapped_column(String, nullable=False) + actor = mapped_column(String, nullable=False) + # 'actor' (The ActiveState platform username) is obstained from the user + # while configuring the publisher We'll make an api call to ActiveState to + # get the 'actor_id' + actor_id = mapped_column(String, nullable=False) + ingredient = mapped_column(String, nullable=True) + + __required_verifiable_claims__: dict[str, CheckClaimCallable[Any]] = { + "sub": _check_sub, + "organization": oidccore.check_claim_binary(str.__eq__), + "project": oidccore.check_claim_binary(str.__eq__), + "actor_id": oidccore.check_claim_binary(str.__eq__), + # This is the name of the builder in the ActiveState Platform that + # publishes things to PyPI. + "builder": oidccore.check_claim_invariant("pypi-publisher"), + } + + __optional_verifiable_claims__: dict[str, CheckClaimCallable[Any]] = { + "ingredient": oidccore.check_claim_binary(str.__eq__), + } + + __unchecked_claims__ = { + "actor", + "artifact_id", + "organization_id", + "project_id", + "project_path", + "project_visibility", + } + + @staticmethod + def __lookup_all__(klass, signed_claims: SignedClaims): + return Query(klass).filter_by( + organization=signed_claims["organization"], + activestate_project_name=signed_claims["project"], + actor_id=signed_claims["actor_id"], + ) + + __lookup_strategies__ = [ + __lookup_all__, + ] + + @property + def sub(self) -> str: + return f"org:{self.organization}:project:{self.activestate_project_name}" + + @property + def builder(self) -> str: + return "pypi-publisher" + + @property + def publisher_name(self) -> str: + return "ActiveState" + + @property + def project(self) -> str: + return self.activestate_project_name + + def publisher_url(self, claims: SignedClaims | None = None) -> str: + return urllib.parse.urljoin( + _ACTIVESTATE_URL, f"{self.organization}/{self.activestate_project_name}" + ) + + def __str__(self) -> str: + return self.publisher_url() + + +class ActiveStatePublisher(ActiveStatePublisherMixin, OIDCPublisher): + __tablename__ = "activestate_oidc_publishers" + __mapper_args__ = {"polymorphic_identity": "activestate_oidc_publishers"} + __table_args__ = ( + UniqueConstraint( + "organization", + "activestate_project_name", + # This field is NOT populated from the form but from an API call to + # ActiveState. We make the API call to confirm that the `actor` + # provided actually exists. + "actor_id", + name="_activestate_oidc_publisher_uc", + ), + ) + + id = mapped_column( + UUID(as_uuid=True), ForeignKey(OIDCPublisher.id), primary_key=True + ) + + +class PendingActiveStatePublisher(ActiveStatePublisherMixin, PendingOIDCPublisher): + __tablename__ = "pending_activestate_oidc_publishers" + __mapper_args__ = {"polymorphic_identity": "pending_activestate_oidc_publishers"} + __table_args__ = ( + UniqueConstraint( + "organization", + "activestate_project_name", + "actor_id", + name="_pending_activestate_oidc_publisher_uc", + ), + ) + + id = mapped_column( + UUID(as_uuid=True), ForeignKey(PendingOIDCPublisher.id), primary_key=True + ) + + def reify(self, session): + """ + Returns a `ActiveStatePublisher` for this `PendingActiveStatePublisher`, + deleting the `PendingActiveStatePublisher` in the process. + """ + # Check if the publisher already exists. Return it if it does. + maybe_publisher = ( + session.query(ActiveStatePublisher) + .filter( + ActiveStatePublisher.organization == self.organization, + ActiveStatePublisher.activestate_project_name + == self.activestate_project_name, + ActiveStatePublisher.actor_id == self.actor_id, + ActiveStatePublisher.actor == self.actor, + ) + .one_or_none() + ) + + publisher = maybe_publisher or ActiveStatePublisher( + organization=self.organization, + activestate_project_name=self.activestate_project_name, + actor_id=self.actor_id, + actor=self.actor, + ) + + session.delete(self) + return publisher diff --git a/warehouse/oidc/utils.py b/warehouse/oidc/utils.py index b66a60a73988..8c71afceb637 100644 --- a/warehouse/oidc/utils.py +++ b/warehouse/oidc/utils.py @@ -20,32 +20,47 @@ from warehouse.oidc.errors import InvalidPublisherError from warehouse.oidc.interfaces import SignedClaims from warehouse.oidc.models import ( + ActiveStatePublisher, GitHubPublisher, GooglePublisher, OIDCPublisher, + PendingActiveStatePublisher, PendingGitHubPublisher, PendingGooglePublisher, + PendingOIDCPublisher, ) -from warehouse.oidc.models._core import OIDCPublisherMixin GITHUB_OIDC_ISSUER_URL = "https://token.actions.githubusercontent.com" GOOGLE_OIDC_ISSUER_URL = "https://accounts.google.com" +ACTIVESTATE_OIDC_ISSUER_URL = "https://platform.activestate.com/api/v1/oauth/oidc" OIDC_ISSUER_SERVICE_NAMES = { GITHUB_OIDC_ISSUER_URL: "github", GOOGLE_OIDC_ISSUER_URL: "google", + ACTIVESTATE_OIDC_ISSUER_URL: "activestate", } OIDC_ISSUER_ADMIN_FLAGS = { GITHUB_OIDC_ISSUER_URL: AdminFlagValue.DISALLOW_GITHUB_OIDC, GOOGLE_OIDC_ISSUER_URL: AdminFlagValue.DISALLOW_GOOGLE_OIDC, + ACTIVESTATE_OIDC_ISSUER_URL: AdminFlagValue.DISALLOW_ACTIVESTATE_OIDC, } -OIDC_ISSUER_URLS = {GITHUB_OIDC_ISSUER_URL, GOOGLE_OIDC_ISSUER_URL} +OIDC_ISSUER_URLS = { + GITHUB_OIDC_ISSUER_URL, + GOOGLE_OIDC_ISSUER_URL, + ACTIVESTATE_OIDC_ISSUER_URL, +} -OIDC_PUBLISHER_CLASSES: dict[str, dict[bool, type[OIDCPublisherMixin]]] = { +OIDC_PUBLISHER_CLASSES: dict[ + str, dict[bool, type[OIDCPublisher | PendingOIDCPublisher]] +] = { GITHUB_OIDC_ISSUER_URL: {False: GitHubPublisher, True: PendingGitHubPublisher}, GOOGLE_OIDC_ISSUER_URL: {False: GooglePublisher, True: PendingGooglePublisher}, + ACTIVESTATE_OIDC_ISSUER_URL: { + False: ActiveStatePublisher, + True: PendingActiveStatePublisher, + }, }