"""Create Keycloak users from the platform. The public buyer sign-up screen needs a Keycloak account before it can write the matching Django row, because that row stores the Keycloak subject id. Realm self-registration is off (``registrationAllowed = false``) and could not capture the company name anyway, so the platform creates the user itself through the Keycloak Admin API, using a dedicated service account (``KEYCLOAK_PROVISIONING_CLIENT_ID``) that holds only ``manage-users`` and ``view-users`` on ``realm-management``. The account it creates is deliberately bare: **no realm roles and no client roles**, so it grants nothing on the platform. The Django row's ``role`` is the authority; Keycloak is only the identity store. Credentials are read from settings (django-environ), never hard-coded, and no token or secret is ever logged. """ import requests from django.conf import settings class KeycloakError(Exception): """Base class for provisioning failures the sign-up screen can report.""" class DuplicateEmailError(KeycloakError): """Keycloak already has a user with this email address.""" class KeycloakUnavailableError(KeycloakError): """Keycloak could not be reached, or refused the service account.""" def _realm_url(): return f"{settings.KEYCLOAK_SERVER_URL.rstrip('/')}/realms/{settings.KEYCLOAK_REALM}" def _admin_url(path): return ( f"{settings.KEYCLOAK_SERVER_URL.rstrip('/')}" f"/admin/realms/{settings.KEYCLOAK_REALM}{path}" ) def _split_name(full_name): """Split a full name for Keycloak's required firstName/lastName fields. The realm's user profile marks both as required for users, and Keycloak inserts a "Update Account Information" (``VERIFY_PROFILE``) step at sign-in whenever one is empty. A single-word name therefore fills both fields: the platform shows ``full_name`` from its own row, so the split only has to satisfy Keycloak, not be a perfect decomposition. """ parts = full_name.strip().split() if not parts: return "", "" if len(parts) == 1: return parts[0], parts[0] return " ".join(parts[:-1]), parts[-1] def _service_account_token(timeout=10): """Exchange the provisioning client's credentials for an admin token.""" try: response = requests.post( f"{_realm_url()}/protocol/openid-connect/token", data={ "grant_type": "client_credentials", "client_id": settings.KEYCLOAK_PROVISIONING_CLIENT_ID, "client_secret": settings.KEYCLOAK_PROVISIONING_CLIENT_SECRET, }, timeout=timeout, ) except requests.RequestException as exc: raise KeycloakUnavailableError("Could not reach Keycloak.") from exc if response.status_code != 200: raise KeycloakUnavailableError("Keycloak refused the provisioning account.") return response.json()["access_token"] def create_keycloak_user(*, email, full_name, password, timeout=10): """Create an enabled, unverified Keycloak user and return its subject id. ``emailVerified`` is left false on purpose: the realm has ``verifyEmail`` off and no mail service exists, so an unverified user can still sign in. A duplicate email (``duplicateEmailsAllowed`` is off) surfaces as :class:`DuplicateEmailError` rather than a raw 409. """ token = _service_account_token(timeout=timeout) first_name, last_name = _split_name(full_name) payload = { "username": email, "email": email, "firstName": first_name, "lastName": last_name, "enabled": True, "emailVerified": False, "credentials": [ {"type": "password", "value": password, "temporary": False} ], } try: response = requests.post( _admin_url("/users"), json=payload, headers={"Authorization": f"Bearer {token}"}, timeout=timeout, ) except requests.RequestException as exc: raise KeycloakUnavailableError("Could not reach Keycloak.") from exc if response.status_code == 409: raise DuplicateEmailError(email) if response.status_code not in (201, 204): raise KeycloakUnavailableError("Keycloak could not create the account.") location = response.headers.get("Location", "") if not location: raise KeycloakUnavailableError("Keycloak did not return the new account.") return location.rstrip("/").rsplit("/", 1)[-1]