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
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -132,3 +132,5 @@ docker-compose.*.yml
certs/*

.claude/

.drf_lint_cache.json
2 changes: 1 addition & 1 deletion .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -80,5 +80,5 @@ repos:
language: python
files: '(serializers\.py$|serializers/.*\.py$)'
additional_dependencies:
- mitol-drf-lint
- "mitol-drf-lint==2026.8.28"
- "setuptools<82"
13 changes: 13 additions & 0 deletions RELEASE.rst
Original file line number Diff line number Diff line change
@@ -1,6 +1,19 @@
Release Notes
=============

Version 1.166.0
---------------

- feat (hq11846): Complete your Purchase, paid-amount-off discount behavior (#3926)
- Retire b2b_contract create --create, demote the org sync to a reconciler (C1 5/5) (#3932)
- Expose the provisioning API under /api/v0/b2b/provisioning/ (C1 4/5) (#3931)
- Provision Keycloak organizations and IdPs at runtime (C1 3/5) (#3930)
- Add the B2B onboarding and identity provider records (C1 2/5) (#3929)
- Give the Keycloak admin client the calls provisioning needs (C1 1/5) (#3928)
- Harden test for locals (#3950)
- chore: pin mitol-drf-lint in the drf-serializer-orm-check hook (#3943)
- Reuse prefetched course runs in the v2 course API again (#3948)

Version 1.165.4
---------------

Expand Down
54 changes: 45 additions & 9 deletions b2b/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
from django.contrib.contenttypes.models import ContentType
from django.core.cache import caches
from django.core.exceptions import ValidationError
from django.db import transaction
from django.db import IntegrityError, transaction
from django.db.models import Count, Manager, Prefetch, Q
from mitol.common.utils import now_in_utc
from opaque_keys.edx.keys import CourseKey
Expand All @@ -32,6 +32,7 @@
MAILGUN_LOGS_DESC,
MAILGUN_LOGS_PAGE_LIMIT,
MAILGUN_LOGS_RETENTION_DAYS,
ONBOARDING_STATE_ORG_CREATED,
ORG_KEY_MAX_LENGTH,
RETIREMENT_CONTRACT_NAME,
RETIREMENT_ORG_KEY,
Expand All @@ -49,6 +50,7 @@
ContractProgramItem,
DiscountContractAttachmentRedemption,
OrganizationIndexPage,
OrganizationOnboarding,
OrganizationPage,
UserOrganization,
)
Expand Down Expand Up @@ -1810,31 +1812,65 @@ def reconcile_keycloak_orgs():
create or update corresponding records in MITx Online. This does not manage
memberships, just base org info.

Since the provisioning API (capability C1) writes both systems together,
this is a drift reconciler rather than the primary create path: it adopts
the organizations Pulumi still owns, ones made in the console, and ones left
behind by a provisioning saga whose compensating delete also failed. That
last case is why it has to see the whole realm, not a first page of it.

Returns
- tuple (created, updated): number of orgs created and updated
"""

org_model = get_keycloak_model(*KCAM_ORGANIZATIONS)
orgs = org_model.list()
orgs = org_model.list_all()
parent_org_page = OrganizationIndexPage.objects.first()
created_count = 0
updated_count = 0

for org in orgs:
try:
page, created = reconcile_single_keycloak_org(org)
# Each org gets its own savepoint. Postgres aborts the whole
# transaction on any failed statement, so catching a database error
# and carrying on with the loop only works if that error was
# contained - otherwise every later query raises
# TransactionManagementError and skipping one org still loses the
# rest of the pass, just less legibly.
with transaction.atomic():
page, created = reconcile_single_keycloak_org(org)

if created:
parent_org_page.add_child(instance=page)
page.save()
parent_org_page.save()
else:
page.save()

# An adopted organization needs an onboarding record too, so
# that orgs that arrived this way show up in the same place as
# the ones the provisioning API made.
OrganizationOnboarding.objects.get_or_create(
organization=page,
defaults={
"state": ONBOARDING_STATE_ORG_CREATED,
"state_changed_at": now_in_utc(),
},
)

# Counted after the savepoint commits, so a rolled-back org is not
# reported as reconciled.
if created:
created_count += 1
parent_org_page.add_child(instance=page)
page.save()
parent_org_page.save()
else:
updated_count += 1
page.save()
except ValidationError: # noqa: PERF203
except (ValidationError, IntegrityError): # noqa: PERF203
# IntegrityError because OrganizationOnboarding.organization is a
# OneToOneField: a concurrent provisioning saga or a second
# reconcile run can insert the row between this one's check and its
# insert. The per-org catch is the point - one org losing that race
# must not abandon the rest of the pass.
log.exception(
"Validation error: could not create or update organization for Keycloak org %s",
"Could not create or update organization for Keycloak org %s",
org.id,
)

Expand Down
20 changes: 20 additions & 0 deletions b2b/api_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@
B2B_RUN_TAG_FORMAT,
CONTRACT_MEMBERSHIP_CODE,
CONTRACT_MEMBERSHIP_MANAGED,
ONBOARDING_STATE_ORG_CREATED,
)
from b2b.exceptions import SourceCourseIncompleteError
from b2b.factories import ContractPageFactory, OrganizationPageFactory
Expand Down Expand Up @@ -929,6 +930,18 @@ def list(self):

return self.orgs

def list_all(self, page_size=None, **kwargs): # noqa: ARG002
"""
Return every fake org.

reconcile_keycloak_orgs pages rather than calling list, because
Keycloak's collection endpoints answer with 10 results when no max
is given and a drift reconciler that sees a first page is not a
reconciler.
"""

return self.orgs

org_model = MockedOrgModel()
org_model.orgs = factories.OrganizationRepresentationFactory.create_batch(3)

Expand Down Expand Up @@ -982,6 +995,13 @@ def list(self):

assert found_count == (3 if not update_an_org else 4)

# An adopted org needs an onboarding record too, so orgs that arrived this
# way show up in the same place as the ones the provisioning API made.
assert all(
org_page.onboarding.state == ONBOARDING_STATE_ORG_CREATED
for org_page in org_pages
)


def test_reconcile_bad_keycloak_org(mocker):
"""Test that reconciliation works when there's bad data"""
Expand Down
67 changes: 67 additions & 0 deletions b2b/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,73 @@
RETIREMENT_ORG_NAME = "Retired Runs"
RETIREMENT_CONTRACT_NAME = "Retired Runs Holding Contract"

# Onboarding states for a B2B organization, in order. `blocked` is reachable
# from anywhere, with the reason in OrganizationOnboarding.notes.
#
# The state is descriptive, not enforcing: it records what has been observed to
# be true so an operator can answer "what is left for this customer" without
# reading four systems. Nothing in the provisioning API gates on it.
ONBOARDING_STATE_REQUESTED = "requested"
ONBOARDING_STATE_ORG_CREATED = "org_created"
ONBOARDING_STATE_IDP_CONFIGURED = "idp_configured"
ONBOARDING_STATE_IDP_VALIDATED = "idp_validated"
ONBOARDING_STATE_CONTRACT_READY = "contract_ready"
ONBOARDING_STATE_LIVE = "live"
ONBOARDING_STATE_BLOCKED = "blocked"

ONBOARDING_STATE_CHOICES = [
(ONBOARDING_STATE_REQUESTED, "Requested"),
(ONBOARDING_STATE_ORG_CREATED, "Organization created"),
(ONBOARDING_STATE_IDP_CONFIGURED, "Identity provider configured"),
(ONBOARDING_STATE_IDP_VALIDATED, "Identity provider validated"),
(ONBOARDING_STATE_CONTRACT_READY, "Contract ready"),
(ONBOARDING_STATE_LIVE, "Live"),
(ONBOARDING_STATE_BLOCKED, "Blocked"),
]

IDP_PROTOCOL_SAML = "saml"
IDP_PROTOCOL_OIDC = "oidc"
IDP_PROTOCOL_CHOICES = [
(IDP_PROTOCOL_SAML, "SAML"),
(IDP_PROTOCOL_OIDC, "OIDC"),
]

IDP_STATE_DRAFT = "draft"
IDP_STATE_TESTING = "testing"
IDP_STATE_ACTIVE = "active"
IDP_STATE_DISABLED = "disabled"

IDP_LIFECYCLE_CHOICES = [
(IDP_STATE_DRAFT, "Draft"),
(IDP_STATE_TESTING, "Testing"),
(IDP_STATE_ACTIVE, "Active"),
(IDP_STATE_DISABLED, "Disabled"),
]

# The lifecycle state is written to Keycloak's own `enabled`/`hideOnLogin` as
# well as our row, so the two cannot drift. `hideOnLogin` stays true
# throughout, matching what the Pulumi resources set today: partner IdPs are
# reached by organization/domain routing or an explicit kc_idp_hint, never by a
# button on the shared login page. `testing` and `active` therefore carry the
# same Keycloak flags -- they differ in whether the organization has an
# email-domain redirect pointing at the IdP, which is org-level config.
IDP_STATE_KEYCLOAK_FLAGS = {
IDP_STATE_DRAFT: {"enabled": False, "hideOnLogin": True},
IDP_STATE_TESTING: {"enabled": True, "hideOnLogin": True},
IDP_STATE_ACTIVE: {"enabled": True, "hideOnLogin": True},
IDP_STATE_DISABLED: {"enabled": False, "hideOnLogin": True},
}

# An IdP goes live only after somebody has actually logged in through it, so
# there is no draft -> active edge. An IdP that has already been through
# testing can be re-enabled directly.
IDP_ALLOWED_TRANSITIONS = {
IDP_STATE_DRAFT: [IDP_STATE_TESTING],
IDP_STATE_TESTING: [IDP_STATE_DRAFT, IDP_STATE_ACTIVE, IDP_STATE_DISABLED],
IDP_STATE_ACTIVE: [IDP_STATE_TESTING, IDP_STATE_DISABLED],
IDP_STATE_DISABLED: [IDP_STATE_TESTING, IDP_STATE_ACTIVE],
}

MAILGUN_LOGS_API_URL = "https://api.mailgun.net/v1/analytics/logs"
MAILGUN_LOGS_PAGE_LIMIT = 100
MAILGUN_LOGS_DESC = "timestamp:desc"
Expand Down
38 changes: 38 additions & 0 deletions b2b/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,3 +16,41 @@ class TargetCourseRunExistsError(Exception):

class KeycloakAdminImproperlyConfiguredError(Exception):
"""Raised if Keycloak admin client is improperly configured."""


class AliasCollisionError(Exception):
"""
Raised when a Keycloak alias is already taken.

Organization and identity provider aliases are realm-wide, and the realm is
shared with the resources Pulumi still declares, so an alias that is free in
our own tables can still collide. Creating one anyway would break the next
pulumi up that declares the same name.
"""


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


class OrphanedKeycloakOrganizationError(Exception):
"""
Raised when a Keycloak organization is left behind by a failed create.

The organization creation saga compensates for a failed MITx Online write by
deleting the Keycloak organization it just made. When that compensating
delete also fails, the organization is orphaned and this is raised with its
ID so the caller can surface it.
"""


class OrganizationNotProvisionedError(Exception):
"""
Raised when an organization has no Keycloak counterpart to act on.

An OrganizationPage with a null sso_organization_id cannot be updated in
Keycloak, because there is nothing there to update. Roughly 24 production
organizations are in this state, inherited from mitodl/hq#10552 and from
the b2b_contract create --create path that made them; they need backfilling
through this API rather than patching.
"""
Loading
Loading