Skip to content
Merged
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
8 changes: 8 additions & 0 deletions RELEASE.rst
Original file line number Diff line number Diff line change
@@ -1,6 +1,14 @@
Release Notes
=============

Version 1.166.3
---------------

- feat: GET /api/v0/products/{id}/user_pricing/ per-user price quote (#3959)
- Return 409 when a new organization's name reuses a page slug (#3955)
- Restrict B2B page access to admins (#3957)
- fix(sentry): cap request bodies at 1KB and scrub Postgres DETAIL rows (#3936)

Version 1.166.2
---------------

Expand Down
10 changes: 10 additions & 0 deletions b2b/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,16 @@ class AliasCollisionError(Exception):
"""


class OrganizationNameCollisionError(Exception):
"""
Raised when a new organization's name would reuse an existing page slug.

The name becomes the OrganizationPage slug, which Wagtail requires to be
unique under the organization index. Names that differ only in case or
punctuation slugify to the same thing, so they collide too.
"""


class InvalidLifecycleTransitionError(Exception):
"""Raised when an identity provider is asked to skip a lifecycle state."""

Expand Down
8 changes: 7 additions & 1 deletion b2b/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -147,13 +147,19 @@ class OrganizationPage(Page):

# Use default promote_panels from Page to allow manual slug editing

@staticmethod
def slug_for_name(name):
"""Return the slug a new organization with this name is saved under."""

return slugify(f"org-{name}")

def save(self, clean=True, user=None, log_action=False, **kwargs): # noqa: FBT002
"""Save the page, and update the slug and title appropriately."""

self.title = str(self.name)

if not self.slug:
self.slug = slugify(f"org-{self.name}")
self.slug = self.slug_for_name(self.name)
Page.save(self, clean=clean, user=user, log_action=log_action, **kwargs)

def get_learners(self):
Expand Down
16 changes: 16 additions & 0 deletions b2b/provisioning.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@
from b2b.exceptions import (
AliasCollisionError,
InvalidLifecycleTransitionError,
OrganizationNameCollisionError,
OrganizationNotProvisionedError,
OrphanedKeycloakOrganizationError,
)
Expand Down Expand Up @@ -190,6 +191,7 @@ def create_organization( # noqa: PLR0913
- OrganizationPage: the new organization
Raises:
- AliasCollisionError: org_key is taken here or in the realm
- OrganizationNameCollisionError: the name's page slug is already taken
- OrphanedKeycloakOrganizationError: the MITx Online write and its
compensating delete both failed
- requests.HTTPError: Keycloak rejected the create
Expand Down Expand Up @@ -220,6 +222,20 @@ def create_organization( # noqa: PLR0913
)
raise ImproperlyConfigured(msg)

# add_child() enforces sibling slug uniqueness with a ValidationError, after
# the Keycloak write. Checking here keeps a duplicate name from creating and
# then compensating a Keycloak organization, and gives the caller a 409.
if (
organization_index.get_children()
.filter(slug=OrganizationPage.slug_for_name(name))
.exists()
):
msg = (
f"An organization named '{name}', or one whose name produces the same "
"page slug, already exists."
)
raise OrganizationNameCollisionError(msg)

# Domains are written verified, with no verification having occurred: staff
# are asserting them. That is defensible only while the asserting party is
# MIT staff, and stops being so the moment the partner-facing wizard (C2)
Expand Down
25 changes: 25 additions & 0 deletions b2b/provisioning_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
from b2b.exceptions import (
AliasCollisionError,
InvalidLifecycleTransitionError,
OrganizationNameCollisionError,
OrganizationNotProvisionedError,
OrphanedKeycloakOrganizationError,
)
Expand Down Expand Up @@ -148,6 +149,30 @@ def test_create_organization_rejects_an_alias_taken_in_the_realm(connection):
connection.organizations.create.assert_not_called()


@pytest.mark.parametrize("name", ["Example University", "example university!"])
def test_create_organization_rejects_a_name_that_reuses_a_page_slug(connection, name):
"""
A name whose slug is taken under the index fails before Keycloak is touched.

Wagtail's own sibling-slug check in add_child() raises a plain
ValidationError, which the API turned into a 500 (MITXONLINE-73J), and only
after a Keycloak organization had been created and had to be deleted again.
"""

create_organization(connection=connection, **_organization_kwargs())
connection.organizations.create.reset_mock()

with pytest.raises(OrganizationNameCollisionError):
create_organization(
connection=connection,
**_organization_kwargs(name=name, org_key="EXAMPLEU2"),
)

connection.organizations.create.assert_not_called()
connection.organizations.delete.assert_not_called()
assert not OrganizationPage.objects.filter(org_key="EXAMPLEU2").exists()


def test_create_organization_compensates_a_failed_local_write(connection, mocker):
"""
A failed MITx Online write deletes the Keycloak organization it made.
Expand Down
7 changes: 3 additions & 4 deletions b2b/views/v0/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
from mitol.common.utils.datetime import now_in_utc
from rest_framework import serializers, status, viewsets
from rest_framework.decorators import action
from rest_framework.permissions import IsAuthenticated
from rest_framework.permissions import IsAdminUser, IsAuthenticated
from rest_framework.response import Response
from rest_framework.views import APIView
from rest_framework_api_key.permissions import HasAPIKey
Expand All @@ -38,7 +38,6 @@
from ecommerce.models import Discount, Product
from main.authentication import CsrfExemptSessionAuthentication
from main.constants import USER_MSG_TYPE_B2B_ENROLL_SUCCESS
from main.permissions import IsAdminOrReadOnly

log = logging.getLogger(__name__)

Expand All @@ -62,7 +61,7 @@ class OrganizationPageViewSet(viewsets.ReadOnlyModelViewSet):
)
)
serializer_class = OrganizationPageSerializer
permission_classes = [IsAdminOrReadOnly | HasAPIKey]
permission_classes = [IsAdminUser | HasAPIKey]
lookup_field = "slug"
lookup_url_kwarg = "organization_slug"

Expand All @@ -73,7 +72,7 @@ class ContractPageViewSet(viewsets.ReadOnlyModelViewSet):
"""

serializer_class = ContractPageSerializer
permission_classes = [IsAdminOrReadOnly | HasAPIKey]
permission_classes = [IsAdminUser | HasAPIKey]
lookup_field = "slug"
lookup_url_kwarg = "contract_slug"

Expand Down
8 changes: 7 additions & 1 deletion b2b/views/v0/provisioning.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
from b2b.exceptions import (
AliasCollisionError,
InvalidLifecycleTransitionError,
OrganizationNameCollisionError,
OrganizationNotProvisionedError,
OrphanedKeycloakOrganizationError,
)
Expand Down Expand Up @@ -77,7 +78,12 @@ class ProvisioningExceptionMixin:
def handle_exception(self, exc):
"""Map provisioning exceptions onto HTTP responses."""

if isinstance(exc, AliasCollisionError | OrganizationNotProvisionedError):
if isinstance(
exc,
AliasCollisionError
| OrganizationNameCollisionError
| OrganizationNotProvisionedError,
):
return Response({"detail": str(exc)}, status=status.HTTP_409_CONFLICT)
if isinstance(exc, InvalidLifecycleTransitionError):
return Response({"detail": str(exc)}, status=status.HTTP_400_BAD_REQUEST)
Expand Down
16 changes: 15 additions & 1 deletion b2b/views/v0/provisioning_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
IDP_STATE_TESTING,
ONBOARDING_STATE_LIVE,
)
from b2b.exceptions import AliasCollisionError
from b2b.exceptions import AliasCollisionError, OrganizationNameCollisionError
from b2b.factories import OrganizationIndexPageFactory, OrganizationPageFactory
from b2b.keycloak_admin_dataclasses import (
OrganizationDomainRepresentation,
Expand Down Expand Up @@ -136,6 +136,20 @@ def test_create_organization_alias_collision_is_a_conflict(admin_drf_client, moc
assert response.json()["detail"] == "taken"


def test_create_organization_name_collision_is_a_conflict(admin_drf_client, mocker):
"""A name whose page slug is taken is 409, not the 500 in MITXONLINE-73J."""

mocker.patch(
"b2b.views.v0.provisioning.create_organization",
side_effect=OrganizationNameCollisionError("name taken"),
)

response = admin_drf_client.post(_organizations_url(), CREATE_BODY, format="json")

assert response.status_code == status.HTTP_409_CONFLICT
assert response.json()["detail"] == "name taken"


def test_keycloak_failure_is_a_bad_gateway(admin_drf_client, mocker):
"""
A failed Keycloak call is 502, not 500.
Expand Down
60 changes: 30 additions & 30 deletions drf_lint_baseline.json
Original file line number Diff line number Diff line change
Expand Up @@ -95,36 +95,36 @@
"ecommerce/serializers/__init__.py:658:26:ORM004",
"ecommerce/serializers/__init__.py:796:22:ORM002",
"ecommerce/serializers/__init__.py:978:28:ORM002",
"ecommerce/serializers/v0/__init__.py:289:17:ORM001",
"ecommerce/serializers/v0/__init__.py:291:18:ORM001",
"ecommerce/serializers/v0/__init__.py:292:18:ORM001",
"ecommerce/serializers/v0/__init__.py:314:26:ORM002",
"ecommerce/serializers/v0/__init__.py:352:42:ORM006",
"ecommerce/serializers/v0/__init__.py:402:26:ORM002",
"ecommerce/serializers/v0/__init__.py:411:35:ORM002",
"ecommerce/serializers/v0/__init__.py:418:20:ORM002",
"ecommerce/serializers/v0/__init__.py:420:19:ORM004",
"ecommerce/serializers/v0/__init__.py:424:35:ORM002",
"ecommerce/serializers/v0/__init__.py:441:35:ORM002",
"ecommerce/serializers/v0/__init__.py:463:4:ORM005",
"ecommerce/serializers/v0/__init__.py:464:4:ORM005",
"ecommerce/serializers/v0/__init__.py:465:4:ORM005",
"ecommerce/serializers/v0/__init__.py:495:15:ORM003",
"ecommerce/serializers/v0/__init__.py:499:15:ORM003",
"ecommerce/serializers/v0/__init__.py:503:15:ORM003",
"ecommerce/serializers/v0/__init__.py:507:18:ORM003",
"ecommerce/serializers/v0/__init__.py:512:15:ORM003",
"ecommerce/serializers/v0/__init__.py:522:24:ORM002",
"ecommerce/serializers/v0/__init__.py:541:12:ORM001",
"ecommerce/serializers/v0/__init__.py:565:22:ORM002",
"ecommerce/serializers/v0/__init__.py:612:22:ORM002",
"ecommerce/serializers/v0/__init__.py:649:46:ORM006",
"ecommerce/serializers/v0/__init__.py:66:12:ORM005",
"ecommerce/serializers/v0/__init__.py:681:15:ORM003",
"ecommerce/serializers/v0/__init__.py:687:20:ORM002",
"ecommerce/serializers/v0/__init__.py:724:26:ORM004",
"ecommerce/serializers/v0/__init__.py:819:22:ORM002",
"ecommerce/serializers/v0/__init__.py:963:28:ORM002",
"ecommerce/serializers/v0/__init__.py:1105:28:ORM002",
"ecommerce/serializers/v0/__init__.py:294:17:ORM001",
"ecommerce/serializers/v0/__init__.py:296:18:ORM001",
"ecommerce/serializers/v0/__init__.py:297:18:ORM001",
"ecommerce/serializers/v0/__init__.py:319:26:ORM002",
"ecommerce/serializers/v0/__init__.py:357:42:ORM006",
"ecommerce/serializers/v0/__init__.py:407:26:ORM002",
"ecommerce/serializers/v0/__init__.py:416:35:ORM002",
"ecommerce/serializers/v0/__init__.py:423:20:ORM002",
"ecommerce/serializers/v0/__init__.py:425:19:ORM004",
"ecommerce/serializers/v0/__init__.py:429:35:ORM002",
"ecommerce/serializers/v0/__init__.py:446:35:ORM002",
"ecommerce/serializers/v0/__init__.py:468:4:ORM005",
"ecommerce/serializers/v0/__init__.py:469:4:ORM005",
"ecommerce/serializers/v0/__init__.py:470:4:ORM005",
"ecommerce/serializers/v0/__init__.py:500:15:ORM003",
"ecommerce/serializers/v0/__init__.py:504:15:ORM003",
"ecommerce/serializers/v0/__init__.py:508:15:ORM003",
"ecommerce/serializers/v0/__init__.py:512:18:ORM003",
"ecommerce/serializers/v0/__init__.py:517:15:ORM003",
"ecommerce/serializers/v0/__init__.py:527:24:ORM002",
"ecommerce/serializers/v0/__init__.py:546:12:ORM001",
"ecommerce/serializers/v0/__init__.py:570:22:ORM002",
"ecommerce/serializers/v0/__init__.py:617:22:ORM002",
"ecommerce/serializers/v0/__init__.py:654:46:ORM006",
"ecommerce/serializers/v0/__init__.py:686:15:ORM003",
"ecommerce/serializers/v0/__init__.py:692:20:ORM002",
"ecommerce/serializers/v0/__init__.py:71:12:ORM005",
"ecommerce/serializers/v0/__init__.py:729:26:ORM004",
"ecommerce/serializers/v0/__init__.py:961:22:ORM002",
"flexiblepricing/serializers.py:147:34:ORM001",
"flexiblepricing/serializers.py:163:43:ORM006",
"flexiblepricing/serializers.py:170:34:ORM001",
Expand Down
Loading