netbox
January 4

Авторизация в Netbox через Keycloak (кастомный pipeline)

В сети есть хороший мануал по настройке авторизации в Netbox через Keycloak, однако он хорошо подходит для новых инсталляций, когда у вас чистая БД, ну или размер таблицы с пользователями и группами не очень большой и вам не составит труда все переделать.

Сложность, с которой я столкнулся, заключалась в том, что используемая нами инсталляция Netbox имеет множество активных пользователей импортированных из Active Directory, активную ролевую модель основанную на группах из Active Directory и потерять все эти ассоциации очень не хотелось бы.

Изучая документацию на модуль python-social-auth (а также код самого модуля и его бэкэнд к Keycloak) я наткнулся на раздел Extending the Piplene и понял что весь конвеер авторизации легко настраивается и изменяется, однако придётся немного по-питонить.

Проблемы с которыми я столкнулся

Собственно их было две:

  1. Дефолтный Pipeline падает при наличии в Netbox пользователей с одинаковыми email (это специфический случай, который характерен для нашей организации и с которым вы можете никогда не столкнуться. Для снятия лишних вопросов скажу что, у нас есть правило записывать в поле email соответствующий email владельца технической учётной записи)
  2. Дефолтный 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',
)

Собственно на этом всё. Спасибо что дочитали, надеюсь это кому-то пригодится.