«Выполнено» — самый дорогой неверный ответ. Как enterprise-функции вскрыли дефекты не в себе, а в стыке со старым кодом

от автора

Продолжение моей эпопеи с self-hosted MDM решением

В прошлый раз я писал, как из за ограничений и необходимости админить смешанный парк корп устройств, появился собственный MDM. Несмотря на то что предыдущий пост вышел совсем недавно, упорная работа над проектом шла параллельно, поэтому готов представить вам продолжение. Параллельно буду прикреплять скрины нового веб интерфейса, чтобы можно было почувствовать разницу.

Dashboard

Dashboard

Изменения

С тех пор в базовой версии изменилось немного, чуть обновил дизайн, добавил необходимый функционал с массовой миграцией с других MDM, групповой энролмент, удаление ПО из веб интерфейса, поправил пару десяток багов и чуть подлотал безопасность. Но теперь о главном: появилась пилотная организация, которая захотела поставить его у себя, а вместе с ней — запрос enterprise версии и чеклист функционала, котрый в больнишстве своем и так был в роадмапе, но добавилось и еще пару новых фич, а главное, что они должны были быть ЖБ, поэтому я и мой коллега, приступили к работе. Итоговый функционал, который мы подготовили к enterprise 1.0: SSO, второй фактор, изоляция подразделений, выгрузка в SIEM, отчётность, бэкапы, LDAP, синхрон с AD, FileVault блокировка мака, тенанты, INFRA like a code. Интересным оказалось другое: почти каждая из этих функций, будучи написанной, вскрывала дефект не в себе, а в стыке со старым кодом. Ниже про то, что при этом сломалось и как чинилось.

Коротко: что появилось

Чтобы дальше было понятно, о чём речь, вот список без подробностей. Мультитенантность — несколько организаций или подразделений на одном сервере, у каждого свои устройства, группы, скрипты и политики. Каталог пользователей: синхронизация людей из LDAP или Active Directory и вход в панель доменным паролем. Корпоративный вход — OIDC, SAML, второй фактор по TOTP, автоматическое заведение и увольнение администраторов через SCIM. Эскроу ключей восстановления FileVault на macOS и принудительная блокировка диска. Экспорт журнала и событий в SIEM, архивирование журнала перед чисткой по сроку хранения, дашборд соответствия, сопоставление установленного ПО с базой уязвимостей. Аудит с хеш-цепочкой. Удалённая перезагрузка машины и группы с отсрочкой для сотрудника. Удаление программ с устройства из панели. Бэкап по расписанию с проверкой восстановления. Конфигурация парка как код, в YAML, в гите.

Journal

Journal

Изоляция, которой не было

Первым большим куском была мультитенантность. Постановка звучит просто: администратор одного подразделения не должен видеть устройства другого. Простой и неправильный способ — добавить колонку tenant_id и не забывать фильтровать по ней в каждом запросе. Ключевое слово тут «не забывать». Один пропущенный WHERE — и это уже не баг выдачи, а утечка между организациями. Хуже того: пропущенный WHERE в коде, который напишут через полгода, ничем не отличается от обычной невнимательности, а последствия у него другие.

Поэтому изоляция ушла в базу — построчные политики PostgreSQL, причём с FORCE ROW LEVEL SECURITY, чтобы политика действовала и на владельца таблицы. Роль, под которой ходит сервер, лишена права обходить RLS. Смысл в том, что теперь запрос, написанный завтра и забывший про тенанта, вернёт не чужие строки, а ничего.

Звучит хорошо. Дальше начинается интересное.

Пустая строка вместо «не задан»

Тенант передаётся в базу через кастомный параметр сессии, который выставляется в начале транзакции: set_config с флагом «на время транзакции». Политика читает его и сравнивает с колонкой. Всё честно ровно до того момента, когда транзакция заканчивается.

Оказалось, что такой параметр после транзакционного set_config возвращается не в состояние «не задан», а в пустую строку. Для PostgreSQL это разные вещи, и для приведения к uuid — тем более. Дальше срабатывает соединение из пула: первый же запрос с привязкой к тенанту «отравляет» соединение, и любой следующий запрос к таблице под RLS, сделанный мимо скоупа, ловит пустую строку вместо идентификатора.

Самое неприятное в этом дефекте — что у него два разных лица. На отравленном соединении запрос падает с ошибкой приведения типа, то есть даёт живую пятисотку в интерфейсе. На свежем соединении из пула тот же самый запрос молча возвращает ноль строк. Мы неделю смотрели на эти два симптома как на две несвязанные проблемы: где-то интерфейс ругается ошибкой, где-то список просто пустой, и оба поведения плавают. Пока не выяснилось, что это одна и та же строчка кода.

Лечение простое: запрос к таблице под RLS обязан идти внутри открытого скоупа тенанта, а если вызывающий код скоуп не открывает вовсе, функция обязана открыть его сама. Второй случай важнее, чем кажется. Когда команда приходит от агента по gRPC, никакого «текущего пользователя» и его тенанта в контексте нет и быть не может: агент аутентифицирован сертификатом устройства, а не человеком. Значит, привязку берём из строки самого устройства.

И отдельно почему тесты этого не ловили. В тестовом окружении роли базы был выставлен параметр по умолчанию, чтобы фикстуры не разваливались. То есть в тестах запрос мимо скоупа всегда «попадал» в тенанта по умолчанию и вёл себя прилично. На боевом сервере параметра у роли нет. Локальный стенд, отличающийся от прода одной строчкой конфигурации, проверял не то, что мы думали. Регресс переписан так, что он проверяет чужой скоуп, а не «работает ли вообще», и обязан падать на коде до правки.

Enrollment

Enrollment

Ключ восстановления, который терялся молча

Второй кусок — эскроу ключей восстановления FileVault. Сценарий такой: администратор включает шифрование диска на маке, ключ восстановления шифруется на публичный ключ получателя и уезжает на сервер в кастодию. Никто, кроме держателя приватного ключа, его прочитать не может; сервер хранит непрозрачный блоб.

Когда сверху легла мультитенантность, у этой подсистемы обнаружилось два дефекта, и тот, что в выдаче, был менее опасным из двух.

Выдача ходила в базу мимо скоупа — то самое отравленное соединение из предыдущей главы, с пятисоткой на одном пути и тихим нулём строк на другом. Плюс отдельная тонкость: скоуп нужно открывать по тенанту того, кто спрашивает, а не по устройству из адреса запроса. Идентификатор устройства приходит снаружи, и привязка к нему отдала бы администратору одного подразделения кастодию чужого.

А вот приём был сломан хуже. У обработчика, принимающего блоб от агента, тенанта в контексте нет. Значит, вставка шла с тем, что база подставляет по умолчанию. И вот тут FORCE RLS начинает все портить: блоб устройства чужого тенанта ложился в тенанта по умолчанию, а под построчной политикой его не видел уже никто. Вообще никто. Ключ восстановления не «попадал не туда» — ОН ИСЧЕЗАЛ, и обнаружилось бы это ровно в тот день, когда его понадобится достать.

Заодно поправили ещё одну вещь из той же оперы. Если сервер присылал агенту задачу типа, которого эта версия агента не знает, агент выполнял пустой скрипт и рапортовал об успехе. В панели — «выполнено», на устройстве — ничего. Теперь такая задача завершается ошибкой с текстом про неподдерживаемый тип и версию агента. Отставание агента должно быть видно как отставание, а не как выполненная работа.

И общее правило, которое из этого выросло: задача не имеет права заканчиваться молчаливым успехом. Сотрудник нажал «Отмена», не ответил за десять минут, за машиной никого нет, шифрование выключено — оператор обязан увидеть именно эту причину. «Выполнено» — самый дорогой из возможных неверных ответов, потому что после него никто не приходит проверять.

Groups

Groups

Корпоративный вход и цена быстрого кода

Блок идентичности — второй фактор, SCIM, SAML — писался быстро. Он же оказался самым богатым на находки при ревью.

Коды восстановления для второго фактора лежали открытым текстом и представляли собой обрезок uuid — около тридцати двух бит энтропии. То есть механизм, который должен спасать при потере телефона, сам был слабым звеном и в базе, и по стойкости.

Второй фактор снимался без подтверждения: чтобы выключить MFA, достаточно было действующей сессии. Захват сессии в такой схеме означает и снятие второго фактора — то есть весь смысл второго фактора.

Политика «требовать MFA у всех» была ловушкой. Сервер требовал второй фактор для доступа к ручке привязки второго фактора. Включив политику, администратор запирал сам себя и всех остальных: привязаться нельзя, потому что не привязан.

SAML принимал любую подписанную assertion. Разбор ответа вызывался с пустым списком ожидаемых идентификаторов запроса, то есть проверка «это ответ на наш запрос» отсутствовала. Пользователь при этом попадал в первое попавшееся членство вместо организации своего провайдера. Плюс пара ключей нашего сервис-провайдера генерировалась заново при каждом старте сервера — то есть настройка в IdP переставала быть валидной после перезапуска.

SCIM не писал аудит (в колонку с идентификатором пользователя уезжала строка «scim») и глотал ошибку удаления при увольнении. Увольнение, завершившееся ошибкой и отрапортовавшее успех, — это лучший подарок аудитору из всех возможных.

Миграции, которые сносили сами себя

Отдельная история, короткая и поучительная.

Схему базы у меня накатывает простой скрипт, который прогоняет SQL-файлы через psql. Down-миграций нет намеренно: откат только из бэкапа. Разработчик, писавший новые миграции, оформил их в популярном формате с аннотациями-комментариями: секция up, секция down.

Для инструмента, понимающего эти аннотации, файл делится на две части, и вторая при обычном накате не выполняется. Для psql аннотация — это просто комментарий. То есть выполняется весь файл целиком, включая то, что после «секции down». Миграция создавала таблицы и тут же их удаляла, а факт применения записывался честно. Схема получалась пустой, номер миграции — свежим.

Ошибка ловится за минуту, если знать, куда смотреть, и не ловится вообще, если не знать. Мораль тут не про goose и не про psql: любой формат, где часть файла «не должна выполняться», опасен ровно настолько, насколько инструмент, который это обеспечивает, отличается от того, который вы реально запускаете.

Agents update

Agents update

Гейты, зелёные на бумаге

А это самая неприятная глава, потому что тут я подставился сам.

Перед пушем у меня прогоняется набор проверок: тесты сервера, линтер, линтер отдельно для корпоративной сборки, сборка и тесты фронтенда. Всё зелёное. Выкатываю. Сервер падает при старте с паникой и лежит шесть минут, пока я откатываюсь.

Причина оказалась в порядке сборки приложения: опции роутера стали применяться раньше, чем объявлялись группы маршрутов, и одна из зависимостей приезжала пустой. Тесты этого не увидели, потому что ни один из них не собирал приложение тем же набором опций, что боевой запуск. Каждый тест собирал свой маленький роутер с тем подмножеством, которое было нужно конкретному тесту.

Вывод: подмножество проверяет то, что вы помнили, а ломается то, что вы забыли. Тест точки сборки обязан собирать систему тем же самым набором опций, что и прод, а не представительным набором. И вторая часть того же вывода: у каждой новой проверки нужно один раз убедиться, что она умеет краснеть. У меня в тот период три проверки подряд оказались зелёными просто потому, что не проверяли ничего — одна сравнивала файл сам с собой, другая молча выходила при отсутствии инструмента.

Бэкап, который не восстанавливался

Пункт чеклиста «бэкапы» я считал скучным и дешёвым. Написать дамп по расписанию, ротацию, положить в отдельный том — фигня.

Проблема нашлась при попытке восстановиться на чистой машине. Дамп базы не содержит ролей кластера — их там нет по устройству, это объект уровня кластера, а не базы. При этом роль, под которой ходит сервер, создаётся миграцией без пароля: пароль задаётся руками при развёртывании. Итого процедура восстановления давала живую базу со всеми данными, к которой сервер не может подключиться. И выяснилось бы это ровно в момент аварии — то есть в момент, когда права на ошибку нет.

Теперь в бэкап входят роли кластера отдельным дампом, в документации они накатываются до базы, а в сам прогон встроена проверка: восстановление в одноразовую базу того же кластера, сверка номера применённой миграции с живой, проверка наличия таблиц и пользователей. Сверяем при этом только то, что не меняется само по себе: счётчики журнала и устройств растут прямо во время бэкапа и мигали бы ложными расхождениями. Потолок у такой проверки понятный — тот же кластер, роли и расширения уже на месте, — и он честно записан в документации. Полноценная проверка на чистой машине остаётся ручной процедурой.

Аудит, который можно проверить

Журнал действий администраторов был с самого начала. Корпоративный чеклист требует другого: доказательства, что журнал не переписывали.

Сделано хеш-цепочкой: каждая запись включает хеш предыдущей. Такая цепочка ловит три вещи — правку записи, удаление из середины и перестановку. Чего она не ловит сама по себе — это переписывания журнала целиком: злоумышленник с доступом к базе может пересчитать всю цепочку заново, и она будет согласованной.

Поэтому голова цепочки ежесуточно публикуется наружу, в лог сервера, откуда её забирает внешний сборщик логов. Сверка с опубликованной головой и отличает «журнал внутренне согласован» от «журнал не переписывали». Это, кстати, хороший пример того, как выглядит честная формулировка свойства: не «журнал защищён от подделки», а «подделка обнаруживается при условии, что якорь уехал наружу до неё».

Второй момент: у журнала есть срок хранения, и чистка по сроку убивала бы ту самую цепочку, ради которой всё затевалось. Поэтому перед удалением записи выгружаются в архив, а в цепочке остаются якоря, переживающие чистку.

Scripts

Scripts

Про границу enterprise

Теперь та часть, которую я в прошлый раз сознательно обошёл одним абзацем.

Инструмент открыт под Apache-2.0 и остаётся открытым. Есть корпоративная редакция. Границу я проводил по одному правилу: базовая безопасность не продаётся. Взаимный TLS, идентичность по сертификату, одноразовые токены подключения, журнал аудита, подпись обновлений агента, политика паролей, блокировки при переборе — всё это в открытой части и никуда оттуда не уедет. Модель pay to secure кажется мне порочной по сути: незащищённый MDM не является продуктом, который вообще стоит ставить.

Enterprise версия — это то, что нужно организации, а не администратору: корпоративная идентичность, изоляция подразделений, работа с ключами восстановления дисков, compliance-отчётность и выгрузка в SIEM, работа с каталогом пользователей. Всё, что описано выше в этом тексте как «блок идентичности» и «эскроу», — оттуда.

Пара технических решений про саму лицензию.

Проверка полностью офлайновая. Сервер несёт вшитый на сборке публичный ключ и проверяет подпись файла лицензии сам. Ни установка, ни работа, ни истечение не требуют связи с моей инфраструктурой — иначе развёртывание в закрытом контуре, которого у корпоративных клиентов хватает, было бы невозможно. Мне это ещё и удобно: инфраструктура, которой нет, не падает и не утекает.

Лицензия двухкомпонентная: подписанный файл плюс пароль активации, которые передаются раздельно. Файл сам по себе бесполезен — это защита от «переезда» ключа между покупателями.

Истечение лицензии ничего не ломает. Корпоративные функции выключаются, операционная часть продолжает работать как открытая редакция, данные остаются на месте. Сервер не превращается в тыкву в полночь.

Код корпоративной части физически отсутствует в открытой сборке — он собирается по сборочному тегу, а не выключается флагом. Причина прагматичная: выключенную проверку в открытых исходниках просто удаляют форком за пять минут.

Polices

Polices

Что по-прежнему не сделано

Один узел — по-прежнему одна точка отказа. И блокер тут не абстрактный «нужно потрудиться», а вполне конкретное место: реестр подключённых устройств живёт в памяти процесса, обычной картой с мьютексом. Вторая нода физически не может послать команду устройству, которое подключено к первой: heartbeat и инвентарь размажутся по общей базе, а командный канал — нет. Вариантов ровно два — маршрутизировать команды через шину (очередь у меня и так стоит, половина дела сделана) или прибивать устройство к ноде на балансировщике. Это проектное решение, и делать его на бегу я не хочу.

Сопоставление установленного ПО с базой уязвимостей сейчас наивное — по вхождению строки версии. На нестандартных схемах версий оно даёт и ложные срабатывания, и пропуски. Это годится как подсказка администратору, куда посмотреть, и не годится как отчёт, который показывают аудитору. Так и написано в документации.

На текущем этапе идет разработка удаленного подключения! Следите за новостями, думаю в дальнейшем буду ограничиваться просто небольшими постами с кратким описанием новых фич!

И главное, что не изменилось: скрипт-канал исполняет произвольный код с максимальными правами на каждом устройстве. Реальный периметр безопасности — хост, на котором стоит сервер, а не TLS и не разграничение ролей внутри панели. Всё, что я написал выше про изоляцию тенантов и второй фактор, не отменяет этой строчки ни на грамм.

Исходники открыты, ссылку оставляю. Как и в прошлый раз, готов к любым обсуждениям!

ссылка на оригинал статьи https://habr.com/ru/articles/1066138/