ixg_platform/apps/users/keycloak.py
ixgadmin 19d67555da Add public buyer self-registration
A UK buyer can now create their own account at /portal/signup/. The screen
creates a Keycloak user through a least-privilege service account
(ixg-provisioning: manage-users, view-users) and the matching Django row as
a buyer, then sends them through the normal sign-in flow to the portal.

Staff status is derived from the role in the OIDC backend rather than
asserted, so a self-registered buyer is not staff and does not become staff
on a later sign-in. The platform root and the RBAC middleware now route
each role to its own portal, which also fixes a redirect loop that the
staff fix exposed for non-staff buyers.
2026-09-21 11:30:29 +00:00

120 lines
4.4 KiB
Python

"""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]