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

Несколько лет назад мне выпала честь взять команду инженеров и «разобраться, как строить в облаке». Не было (в основном) никаких руководств, только смутное представление о том, какое приложение мы должны были создать. Звучит как мечта разработчика, верно?

Это было.

Но выяснение того, как встроить облако, является значительно более сложной задачей, чем это может показаться с точки зрения предприятия. Нам нужно было понять, что такое облако, что вообще такое CI/CD, узнать все тонкости бессерверных вычислений, научиться проектировать модель данных NoSQL, принять решения о том, как структурировать микросервисы, узнать о анализ затрат на облачные технологии и прогнозы, а в довершение всего — узнайте, что такое мультиарендность и как создать приложение, которое с ней справится.

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

Из всех мыслей, концепций, архитектур, фреймворков и шаблонов проектирования, через которые мы прошли, пожалуй, одной из самых фундаментально сложных вещей, через которые мы прошли, была мультиарендность. Это не было новой концепцией для нас в теории, но ее применение на практике было утомительным.

Что такое мультиарендность?

При обсуждении мультиарендности у меня всегда возникает один и тот же вопрос: «Что такое арендатор?»

Честно говоря, на этот вопрос нет ни одного правильного ответа. Для нас арендатор был платным покупателем. Для других это может быть что-то совершенно другое.

Это не потому, что наша реализация мультиарендности была уникальной; это просто то, как мы решили добавить сегментацию в наше приложение.

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

В многопользовательской среде у вас есть много (потенциально неограниченных) групп пользователей в вашей системе, которые совместно используют один и тот же экземпляр программного обеспечения. Эти группы пользователей имеют доступ к тому же набору данных, что и другие, но не имеют доступа к данным других групп.

Эта парадигма отлично подходит для больших приложений, поскольку упрощает управление выпусками. Общий экземпляр подразумевает, что у вас нет развертывания для каждого клиента.

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

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

Бессерверная реализация

Чтобы проиллюстрировать концепции, у меня есть репозиторий GitHub с полнофункциональным многопользовательским приложением, которое мы рассмотрим. В этом репозитории развертывается бессерверное приложение на основе ролей, которое управляет парками штатов.

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

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

В нашем эталонном приложении пользователю могут быть назначены разные роли в зависимости от арендатора. Это позволяет использовать различное управление доступом, когда пользователь может иметь повышенные привилегии в одном арендаторе, но не в другом.

Авторизация

В то время как Amazon Cognito имеет ряд вариантов мультиарендности, наш сегодняшний пример будет сосредоточен на авторизаторе Lambda на основе запросов для оценки личности вызывающего абонента и определения его активного арендатора. Авторизатор Lambda находится перед шлюзом API, оценивает предоставленный токен аутентификации и возвращает политику IAM для пользователя.

В нашем примере мы будем использовать контекст авторизатора, который представляет собой расширенные данные, передаваемые нижестоящим службам как часть политики авторизатора. Эти данные могут быть какими угодно, и наш пример включает в себя сведения о пользователе, совершающем вызов.

Вот пример данных контекста запроса, возвращаемых нашим авторизатором:

{ 
  "userId": "testuserid",
  "tenantId": "texas",
  "email": "[email protected]",
  "roles": "[\"admin\"]",
  "firstName": "Test",
  "lastName": "User" 
}

Схема бизнес-процесса, которой следует наш авторизатор Lambda, показана на диаграмме ниже:

  1. Проверить JWT — механизм проверки подлинности в рабочем процессе проверяет JWT, указанный в заголовке авторизации.
  2. Загрузить сведения о пользователе из DynamoDB. После анализа идентификатора пользователя из JWT загрузите полные данные о пользователе из базы данных. Он содержит активного арендатора, роли и демографическую информацию пользователя.
  3. Определите политику доступа — на основе ролей активного арендатора создайте политику IAM для разрешенных конечных точек, которые может вызывать пользователь.
  4. Создание контекста авторизатора — создание объекта данных, содержащего информацию о пользователе, для предоставления нижестоящим службам, таким как Lambda, DynamoDB и Step Functions.
  5. Политика возврата и контекст — передайте политику доступа обратно в шлюз API, чтобы оценить и определить, есть ли у вызывающего абонента доступ к конечной точке, которую он вызывает.

После того, как авторизатор завершит работу и шлюз API оценит политику IAM, задействуются вспомогательные службы конечной точки и предоставляется информация о вызывающем объекте.

Использование контекста авторизатора обеспечивает дополнительный уровень безопасности в многопользовательских средах. Он не позволяет злоумышленникам предоставлять неверную информацию об арендаторе и получать доступ к данным, которыми они не владеют.

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

Доступ к контексту авторизатора осуществляется по-разному в зависимости от нижестоящей службы. С помощью лямбда-функций мы можем получить доступ к расширенной информации через объект requestContext в событии.

exports.handler = async (event) => { 
  const tenantId = event.requestContext.authorizer.tenantId; 
}

Однако в VTL, когда мы подключаем API Gateway напрямую к таким службам, как DynamoDB и Step Functions, доступ к нему осуществляется по несколько другому пути.

#set($tenantId = $context.authorizer.tenantId)

Теперь, когда у нас есть доступ к идентификатору клиента для вызывающего абонента, давайте обсудим, как мы его используем.

Контроль доступа к данным

Наиболее важным аспектом мультиарендности является предотвращение просмотра пользователями одного арендатора данных, принадлежащих другому арендатору. Другой способ сказать это - иметь сильную изоляцию данных.

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

В качестве примера возьмем следующий набор данных:

В этом наборе данных у вас есть парки от трех разных арендаторов: texas, washington и colorado. Как ключ раздела первичного ключа, так и pk GSI имеют префикс идентификатора арендатора в данных.

Всегда добавляйте к ключам секции первичного ключа и индексов префикс идентификатора арендатора в мультитенантном приложении.

Этот шаблон заставляет нас предоставлять идентификатор арендатора при выполнении операции GetItem или Query. Требуя, чтобы вызов включал идентификатор арендатора, вы гарантируете, что получите данные только от одного арендатора. Вы также гарантируете, что получите данные для правильного арендатора, потому что используемый вами идентификатор исходит от авторизатора Lambda и не подделывается вызывающим абонентом.

Примечание. Этот шаблон не работает при сканировании таблицы. Вы не можете гарантировать доступ к данным одного арендатора при выполнении сканирования. Это еще одна причина, по которой вам следует делать сканирование как можно реже!

Масштабирование инфраструктуры и лимиты обслуживания

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

В предыдущем посте о обходе ограничений на бессерверные сервисы я рассказал о странном несоответствии между количеством тем SNS и количеством фильтров подписки SNS, которые вы можете иметь в своей учетной записи AWS. Вы можете создать 100 000 тем SNS, но только 200 фильтров подписки. Оба ограничения являются мягкими, но их важно учитывать при работе с мультитенантным приложением.

Вместо того, чтобы иметь набор статических тем SNS, которые позволяют пользователям подписываться с фильтром арендатора, более безопасным решением было бы динамическое создание тем SNS для каждого арендатора для каждого типа события. Вы получаете немного более сложное решение, но вы получаете свободу масштабирования, не беспокоясь.

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

При публикации события в динамической теме выполняется простой поиск темы, если она существует. Если это так, мы публикуем его, и публикация пропускается, если он не существует.

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

Когда вы группируете и ставите в очередь, вы теряете синхронный характер стандартных парадигм запроса/ответа REST API. Это важное соображение, потому что ваш идентификатор арендатора внедряется в систему от вашего авторизатора Lambda. Не теряйте его!

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

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

Работа в большом масштабе не слишком отличается от мелкого масштаба строго с точки зрения мультиарендности. Пока вы вводите идентификатор арендатора в точку входа запроса, больше ничего не меняется.

Заключение

Использование нескольких арендаторов в вашем приложении, безусловно, имеет свои недостатки. Вы получаете меньшую сложность и удобство сопровождения за счет поддержки только одного экземпляра вашего приложения. Но вы получаете повышенную сложность, добавляя арендную логику в свое приложение.

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

Также следите за лимитами услуг. Некоторых из менее известных ограничений легко достичь, поскольку ваше приложение масштабируется с большим количеством арендаторов. Всегда рекомендуется смотреть на все ограничения услуг, которые вы будете использовать, прежде чем приступать к созданию.

Арендатор — это логическая конструкция. Как вы это реализуете, полностью зависит от вас. Пока вы держите свои данные изолированными и создаете свое приложение таким образом, чтобы его можно было масштабировать, не существует неправильного способа сделать это. Вы можете использовать Amazon Cognito и его невероятный набор функций или взять его на себя для точного контроля доступа, как это сделал я.

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

Удачного кодирования!