Авторизация в Netbox через Keycloak (кастомный pipeline)
В сети есть хороший мануал по настройке авторизации в Netbox через Keycloak, однако он хорошо подходит для новых инсталляций, когда у вас чистая БД, ну или размер таблицы с пользователями и группами не очень большой и вам не составит труда все переделать.
Сложность, с которой я столкнулся, заключалась в том, что используемая нами инсталляция Netbox имеет множество активных пользователей импортированных из Active Directory, активную ролевую модель основанную на группах из Active Directory и потерять все эти ассоциации очень не хотелось бы.
Изучая документацию на модуль python-social-auth (а также код самого модуля и его бэкэнд к Keycloak) я наткнулся на раздел Extending the Piplene и понял что весь конвеер авторизации легко настраивается и изменяется, однако придётся немного по-питонить.
Проблемы с которыми я столкнулся
- Дефолтный Pipeline падает при наличии в Netbox пользователей с одинаковыми email (это специфический случай, который характерен для нашей организации и с которым вы можете никогда не столкнуться. Для снятия лишних вопросов скажу что, у нас есть правило записывать в поле email соответствующий email владельца технической учётной записи)
- Дефолтный Pipeline не обновляет членство в группах (а вот это уже критично для ролевой модели доступа основанной на группах).
Дефолтный Pipeline
Дефолтный pipeline для Netbox определяется в модуле <your-netbox-folder>/netbox/netbox/settings.py и выглядит так:
SOCIAL_AUTH_PIPELINE = ( 'social_core.pipeline.social_auth.social_details', 'social_core.pipeline.social_auth.social_uid', 'social_core.pipeline.social_auth.social_user', 'social_core.pipeline.user.get_username', 'social_core.pipeline.social_auth.associate_by_email', 'social_core.pipeline.user.create_user', 'social_core.pipeline.social_auth.associate_user', 'netbox.authentication.user_default_groups_handler', 'social_core.pipeline.social_auth.load_extra_data', 'social_core.pipeline.user.user_details', )
Он содержит функции из дефолтного, для модуля python-social-auth, конвеера, за исключением функции user_default_groups_handler из модуля <your-netbox-folder>/netbox/netbox/authentication.py (строка 'netbox.authentication.user_default_groups_handler').
Решение проблемы №1
В первых трех функциях Pipeline происходит получение и декодирование JWT access_token из которого извлекаются различные поля, которые передаются клиенту (в нашем случае Netbox) от Keycloak, и помещаются в переменную details (dict содержащий данные о пользователе, на базе которых пайплайн должен будет создать или обновить данные пользователя Netbox).
Функция get_username создает на базе совокупности различных настроек приложения и модуля python-social-auth имя пользователя, а функция associate_by_email пытается по email найти в БД Netbox соответствующего пользователя.
Функция associate_by_email выглядит так:
def associate_by_email(backend, details, user=None, *args, **kwargs): """ Associate current auth with a user with the same email address in the DB. This pipeline entry is not 100% secure unless you know that the providers enabled enforce email verification on their side, otherwise a user can attempt to take over another user account by using the same (not validated) email address on some provider. This pipeline entry is disabled by default. """ if user: return None email = details.get("email") if email: # Try to associate accounts registered with the same email address, # only if it's a single object. AuthException is raised if multiple # objects are returned. users = list(backend.strategy.storage.user.get_users_by_email(email)) if len(users) == 0: return None elif len(users) > 1: raise AuthException( backend, "The given email address is associated with another account" ) else: return {"user": users[0], "is_new": False}
Очевидно, что при наличии в в БД нескольких пользователей в одинаковым email эта функция упадёт.
Чтобы решить эту проблему я создал новый модуль и переписал эту функцию так, чтобы использовать для ассоциации не только поле email но username.
import re from social_core.exceptions import AuthException def associate_by_email_and_username(backend, details, user=None, *args, **kwargs): """ Associate current auth with a user with the same email address and username in the DB. """ if user: return None email = details.get("email") username = details.get("username") # If username in upn format, get username part if m:=re.match(r"^(?:(?P<username>[^@]+)@(?P<domain>.+))quot;, username): username = m.group('username') if email: users = list(backend.strategy.storage.user.get_users_by_email(email)) if len(users) == 0: return None elif len(users) > 1: for u in users: if u.username == username: return {"user": u, "is_new": False} raise AuthException( backend, "The given email address is associated with another account" ) else: return {"user": users[0], "is_new": False} else: raise AuthException(backend, "No email address returned")
Функция извлекает из словаря details username, если он в формате userPrincipalName - извлекает из него username часть и при наличии в БД Netbox пользователей с одинаковым email проводит дополнительную проверку по полю username.
Остается только собрать пакет из модуля, импортировать его в venv окружение, в котором работает Netbox, и переопределить в настройках приложения Pipeline (об этом расскажу ниже).
Решение проблемы №2
Изучив встроенную функцию netbox.authentication.user_default_groups_handler я понял что она назначает пользователю только группы определенные в переменной REMOTE_AUTH_DEFAULT_GROUPS из configuration.py. Я принял решение заменить эту функцию в конвеере совместив с функциональностью обновления групп, полученных от Keycloak.
Код этой функции будет выглядеть так:
Важно! Для того чтобы эта функция корректно работала access_token, который возвращает ваш Keycloak после авторизации, должен содержать поле groups с массивом имён групп. Если этого поля нет или оно содержит пустой массив - пользователю Netbox будут сброшены все связи с группами, а новых связей создано не будет.
import logging from django.conf import settings from django.contrib.auth.models import Group def update_user_groups(backend, response, user=None, *args, **kwargs): """ Custom pipline handler with adds remote auth users to extist groups or create non-exist groups """ logger = logging.getLogger( "netbox-social-auth-custom.auth_username.update_user_groups_handler" ) if not user: return None else: user_groups = [] auth_groups = response.get("groups", []) user.groups.clear() # Add or Create DEFAULT USER GROUPS defined in configuration.py for name in settings.REMOTE_AUTH_DEFAULT_GROUPS: group, created = Group.objects.get_or_create(name=name) user_groups.append(group) # Add or Create Social Auth user groups # groups should be added to access token as array (list) of names for name in auth_groups: group, created = Group.objects.get_or_create(name=name) user_groups.append(group) if user_groups: user.groups.add(*user_groups) else: logger.info( f"No any groups assignments for {user}. May be REMOTE_AUTH_DEFAULT_GROUPS incorrectly set\ or not returned from OAuth provider" )
Переопределение Pipeline
Итак, обе функции помещены в файл, который я назвал auth_username.py, сложены в папки netbox-social-auth-custom/pipeline (вторая папка больше для красоты в названии - обозначения что это часть pipeline по аналогии с модулем social_core) и из этого собран python package (как собирать python-пакеты я описывать не буду, т.к. для этого есть куча разного инструментария и соответствующих гайдов - например такой, разве что подскажу, что не плохо бы при сборке указать зависимость от social-auth-core>=4.5).
Пакет установлен в окружении venv нашего Netbox и остается только переопределить собственно сам Pipline. Для этого в файле configuration.py переопределяется переменная SOCIAL_AUTH_PIPELINE в виде вот такого массива:
SOCIAL_AUTH_PIPELINE = ( 'social_core.pipeline.social_auth.social_details', 'social_core.pipeline.social_auth.social_uid', 'social_core.pipeline.social_auth.social_user', 'social_core.pipeline.user.get_username', 'netbox-social-auth-custom.pipeline.auth_username.associate_by_email_and_username', 'social_core.pipeline.user.create_user', 'social_core.pipeline.social_auth.associate_user', 'social_core.pipeline.social_auth.load_extra_data', 'social_core.pipeline.user.user_details', 'netbox-social-auth-custom.pipeline.auth_username.update_user_groups', )
Собственно на этом всё. Спасибо что дочитали, надеюсь это кому-то пригодится.