Всем привет! Меня зовут Константин, я инженер-программист в Контуре. Пару лет назад мне довелось поработать над задачей разделения прав доступа в проекте Реестро (7 продуктов, более 100 микросервисов).

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

Структура проекта и о чем проект

Наш проект в рамках всех продуктов состоит из трех основных слоев:

  1. Web: фронтенд и Node.js бэкенд, который пишут фронтендеры (но не во всех продуктах).

  2. Web-Back: API, которые пишут бэкендеры на Kotlin для удобства взаимодействия с нашим транспортом и агрегацией данных.

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

В этой статье буду рассматривать первые два слоя — Web и Web-Back.

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

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

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

Модель авторизации

Для начала остановимся на общей модели авторизации. В основном авторизация состоит из трех компонентов:

  1. Субъект (subject/principal) — лицо, которое обладает или не обладает определенным доступом

  2. Ресурс (Record, Resource) — объект, доступом к которому выдан субъекту

  3. Действие (Action) — то, что субъект хочет сделать с ресурсом 

Обладая этими тремя компонентами, мы можем ответить на вопрос: «Имеет ли конкретный субъект доступ выполнить действие над ресурсом»? 

В нашем случае, процесс следующий. Например: субъект захотел получить список сделок. Он обращается к ресурсу. Тот в свою очередь выполняет некоторые проверки, имеет ли доступ к сделкам пользователь. Если да, то отдаются сделки. Если нет, то в ответ пользователь получает ошибку.

Как работала система прав раньше?

На старте проекта авторизация была выстроена на базе флажков. У пользователя хранился список пермиссий, которые ему разрешены или запрещены, и флаг (доступно/недоступно). Также у пользователя была роль, с помощью которой производилось редактирование доступов клиенту.

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

Преимущества схемы:

  1. Легко добавлять новые пермиссии (когда их немного).

  2. Легко внедрить.

  3. Относительно легко редактировать (если редактируются роли, а не пермиссии).

Недостатки схемы:

  1. Размазанность проверок.

  2. Редактирование прав только ролями.

  3. Отсутствие проверок на выполнение определенных действий на бэкенде.

Основная проблема — отсутствие безопасности с точки зрения прав на стороне бэкенда. Когда человек, отвечавший за Node.js бэкенд, покинул компанию, зоопарк сервисов на Node.js достался бэкендерам, и стало очевидно, что нужны изменения.

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

Рождение Web-back

Главная задача этого слоя — предоставлять более удобное API для фронтов. Схема превратилась в нечто подобное:

Схема не самая удачная, потому что:

– Нет никакого задела под масштабируемость по сценариям проверок.

– Нет проверок права выполнять те или иные операции у конкретных пользователей в конкретных организациях на стороне нашего Web-back.

Мы перетащили много функционала на Web-back из Node.js бэкенда. Однако на тот момент никто не подумал о том, что Web-back остался без проверок прав. 

Возникшие потребности бизнеса

Появились потребности у бизнеса:

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

  • Ограничить просмотр определенных данных в интерфейсе (чтобы только определенные сотрудники могли их видеть).

Это побудило нас перейти от флажков к пермиссиям и начать перенесение проверок на бэкенд.

Итак, все началось с переезда на пермиссии:

В рамках этого решения мы хотели закрыть потребности поступившие от клиентов:

– расширили список пермиссий

– отмигрировались со старых флажков на пермиссии

– добавили редактирование каждого разрешения гранулярно (в отвязки от роли)

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

  1. Редактирование прав должно быть таким же простым, как и раньше (возможно, должны быть пресеты)

  2. Мы должны валидировать права на стороне бэкенда

  3. Должна быть заложена возможность масштабироваться (под другие наши продукты)

  4. Также должна быть возможность фронтам получать готовые “флажки” под кнопочки.

Так, решение свелось к нашему Api-Gateway. Мы проверяем запросы на право исполнения на стороне Api-Gateway.

1 этап. Получение контекста

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

2 этап. Верификация доступа

Берем контекст запроса и выделяем ресурс, действие и субъекта. После этого делаем валидацию прав на стороне авторизационного сервиса.

Методы валидации прав доступа

При выборе подхода для разграничение прав доступа, остановился на самых распространенных подходах: ACL, RBAC и ABAC. Они ранжируются по разной степени детализации (насколько точно можно разграничить права) и масштабируемости. Для простоты приведу примеры по каждому из подходов на небольших примерах.

Для сравнения всех трех подходов внедрим общий интерфейс:

interface Authorizer {
  fun authorize(subject: User, resource: Resource, action: Action): Boolean
}

Access Control List (ACL)

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

enum class DealOperation { 
  CREATE,
  READ, 
  UPDATE, 
  DELETE
} 

Сделка:

data class Deal(
  val id: String, 
  val dataCollection: DataCollection,
  val creatorId: string
  val accountId: UUID
)

Сам по себе пользователь будет выглядеть следующим образом:

data class User(
  val id: String,
  val accountId: UUID
)

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

class DealAccessControlList { 
  private val permissionsMap = mutableMapOf<User, MutableSet<DealOperation>>()
  	
  fun grantPermission(user: User, operation: DealOperation) { 
    permissionsMap.computeIfAbsent(user) { mutableSetOf() }.add(operation)
  } 

  fun revokePermission(user: User, operation: DealOperation) { 
    permissionsMap[user]?.remove(operation)
  } 

  fun hasPermission(user: User, operation: DealOperation, ): Boolean { 
    return permissionsMap[user]?.contains(operation) ?: false 
  } 
}

В результате, чтобы проверить, обладает ли субъект (User) правом на выполнение определенного действия над сущностью, нужно будет встроить проверку в соответствующие методы API. Например, сделаем это на уровне целевого сервиса по сделкам:

class AclAuthorizer(
  private val acl: DealAccessControlList
) : Authorizer { 
  override fun authorize(subject: User, resource: Resource, action: Action): Boolean {
    return acl.hasPermission(subject, action) 
  } 
}

Преимущества ACL:

  1. Подходит для небольшого набора операций и пользователей

  2. В явном виде описываются, кто и какие права имеет

  3. Относительно простой

Недостатки ACL:

  1. Тяжело редактировать при большом количестве прав и юзеров

  2. Не подходит в сценариях, где нужно часто и точечно редактировать

  3. Не дает гибкости при масштабировании на несколько продуктов

Role Based Access Control (RBAC)

Теперь взглянем на более современный относительно ACL подход — ролевая модель. Ролевая модель доступа зачастую более гибкая, нежели стандартный список контроля доступа, так как RBAC уменьшает количество настроек, группируя права в роли. Это облегчает управление доступом при увеличении количества пользователей и операций.

data class Permission( 
  val actions: List<Action>, 
  val resources: List<String> // Список типов ресурсов 
) 

data class Role( 
  val name: String, 
  val subjectIds: List<String>, 
  val permissions: List<Permission> 
) { 
  fun hasSubject(subjectId: String): Boolean { 
    return subjectIds.contains(subjectId) 
  } 

  fun checkAccess(action: Action, resource: Resource): Boolean { 
    return permissions.any { it.actions.contains(action) && 
      it.resources.contains(resource.type) } 
  } 
}

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

class RbacAuthorizer(
  private val roles: List<Role>
) : Authorizer { 
  override fun authorize(subject: User, resource: Resource, action: Action): Boolean { 
    return roles.any { it.hasSubject(subject.id) && it.checkAccess(action, resource) } 
  } 
}

Преимущества RBAC:

  1. Группировка всех разрешений для роли (а не для каждого субъекта в системе)

  2. Последующее оперирование доступов осуществляется выдачей роли

Недостатки RBAC:

  1. Нет возможности точечно отредактировать права

  2. При масштабировании сценариев могут возникать пересечения (в том числе для запрещающих прав)

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

Attribute Based Access Control (ABAC)

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

Авторизатор будет выглядеть следующим образом:

data class Rule( 
  val entity: String,
  val attribute: String, 
  val operation: String, 
  val destinationValue: String 
) 

data class Policy( 
  val subjectIds: List<String>, 
  val actions: List<Action>,
  val rules: List<Rule> 
) 

class AbacAuthorizer(val policies: List<Policy>) : Authorizer { 
  override fun authorize(subject: User, resource: Resource, action: Action): Boolean { 
    return policies.any { policy -> 
      policy.subjectIds.contains(subject.id) && 
      policy.actions.contains(action) && 
      policy.rules.all { rule -> 
        val resourceValue = resource.getAttribute(rule.attribute) 
        applyOperation(rule.operation, resourceValue, rule.destinationValue) 
      } 
    } 
  } 	
}

На входе есть список политик. Мы ищем среди этих политик субъекта и действие. Если не находим, то блокируем доступ. Если же нашли политику, то идем по списку вложенных правил и выполняем сравнение по заданной операции. Например, так может выглядеть политика доступа «Только мои сделки»:

Policy(
  name = "ReadOnlyMyDeals",
  subjectIds = listOf(
    "TestUser1"
  ),
  actions = listOf(
    "Read"
  ),
  rules = listOf(
    Rule(
      entity = "drafts",
      attribute = "createdBy",
      operation = "=",
      destinationValue = "TestUser1"
    )
  )
)

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

Плюсы ABAC:

  1. Инкапсуляция метаинформации за рамками ресурса

  2. Бизнес-ориентированные правила проверок

  3. Гранулярное редактирование прав доступа

Минусы ABAC:

  1. Сложнее внедрить

  2. Требует более сложных проверок на уровне ресурсов

  3. Необходима группировка операций для удобства бизнеса.

Архитектура нашего решения

Валидация

Мы выбрали ABAC как наиболее перспективный вариант. Действие выражается в HTTP-методе запроса и пути (URL). Ресурс — конкретная организация или сущность, в рамках которой выдаются доступы. Чем детализированнее нужен доступ, тем больше информации можно заложить в ресурс.

В сам ресурс мы заложили все требующиеся атрибуты, например: «Только мои сущности,  всего порядка 40 атрибутов на текущий момент».

Рассмотрим архитектуру нашего решения. На стороне Api-Gateway мы выполняем проверки прав через походы в наш самописный авторизационный сервис.

Модель операции в нашем решении: 

data class Operation(
  val id: String,
  val httpMethod: String,
  val path: String // Регулярное выражение для валидации пути
)

Данные операций подгружаются при запуске приложений по каждому из продуктов. Для каждого продукта они описаны в формате csv. Пример:

operation_name;method_path_regex;http_methods;storage_name;description
DRAFTS_API_CREATE_DRAFT;.*realty/drafts-api/v[1-9]{1,2}/drafts;POST;drafts-api.create;создание черновика сделки
DRAFTS_API_DELETE_DRAFT;.*realty/drafts-api/v[1-9]{1,2}/drafts/[\da-fA-F]{24};DELETE;drafts-api.delete;удаление сделки
DRAFTS_API_EDIT_DRAFT;.*realty/drafts-api/v[1-9]{1,2}/drafts/[\da-fA-F]{24};PATCH;drafts-api.edit;обновление данных сделки
DRAFTS_API_GET_DRAFT;.*realty/drafts-api/v[1-9]{1,2}/drafts/[\da-fA-F]{24};GET;drafts-api.get;получение сделки
DRAFTS_API_GET_DRAFTS;.*realty/drafts-api/v[1-9]{1,2}/drafts;GET;drafts-api.get.all;получение списка сделок

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

Модель юзера примерно следующая: 

data class ReestroUser(
  val id: String,
  val accountId: UUID,
  val attributes: Map<String, Any>,
// остальные данные не привожу
) 

Дальше происходит поиск операций во-первых, в рамках продукта, во-вторых, по методу и пути, к которому делается вызов.

suspend fun geOperation(method: String, path: String, productCode: Int? = null): Operation {
  return when (serviceIdProvider.getId(productCode)) {
    ServiceId.REESTRO -> reestroOperations.fromPathAndMethod(method, path)
    ServiceId.INSPECTION -> inspectionOperations.fromPathAndMethod(method, path)
  } ?: throw NotRecognizeApiOperation("Don't recognize path: $path and method: $method for product: ${serviceIdProvider.getId(productCode).productName}")
}

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

В рамках разрешения можно заложить ряд атрибутов. У нас атрибуты добавляются на уровень запроса, и попадают таким образом в целевое API. Если запрос на чтение, то на своем уровне API создает фильтр в базу, куда подставляет соответствующие данные из запроса. Сам процесс доставания атрибутов реализован через CoroutineContext.

Примеры такого контекста:

class RequestContextFilter(
  private val order: Int
) : CoWebFilter(), Ordered {
  private val reestroAttributesResolver = ReestroAttributesAccessResolver()
  override fun getOrder() = order

  override suspend fun filter(exchange: ServerWebExchange, chain: CoWebFilterChain) {
    val userDetails = reestroAuthResolver.resolve(exchange.request.headers)
    val attributes = reestroAttributesResolver.resolve(exchange.request.headers)

    withContext(RequestContext(userDetails, attributes)) {
      chain.filter(exchange)
    }
  }
}

Таким образом на стороне целевого сервиса из кода можно получить RequestContext.attributes и собрать свой фильтр относительно этих данных. Например:

suspend fun enhanceFilterByContext(): SomeFilter { 
  val attributes = RequestContext.current().attributes 
  // Логика для построения фильтра на основе атрибутов 
  return SomeFilter(attributes) 
}

Редактирование прав

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

Внутри авторизационного сервиса на текущий момент порядка 300 операций. Заставлять пользователя все протыкивать — безумие. Поэтому мы придумали поверх таких низкоуровневых прав доступа свою обертку BusinessRight = { List<Operation> }. В общем случае для пользователя ценность представляет именно бизнесовая группировка операций. 

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

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

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

Как фронтенд работает с разрешениями

На стороне фронтенда требуется определенным образом обрабатывать пермиссии. Чтобы сделать это проще, мы денормализовали флажки до уровня сущностей. То есть наполнение флажков производится целевым сервисом, куда ушел запрос.

Пример на сделках:

При запросе списка сделок, целевой сервис возвращает не только сами сделки. У каждой сделки есть также набор флажков, определяющих, что пользователь может делать с этой сделкой.

{
  "deals": [
    {
      "id": "deal1",
      "data": { ... },
      "permissions": {
        "canRead": true,
        "canUpdate": false,
        "canDelete": false, 
        "canSend": false,
        ...
      }
      ...
    },
    {
      "id": "deal2",
      "data": { ... },
      "permissions": {
        "canRead": true,
        "canUpdate": true,
        "canDelete": false,
        "canSend": true
        ...
      }
      ...
    }
  ]
}

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

Результаты и выводы

Итоги нашего решения:

  1. Стали безопасными на бэкенде с точки зрения неавторизованного доступа

  2. Появилось единое место проверки прав доступа

  3. Адаптировали бизнес-пермиссии в инфраструктурные

  4. Относительно быстро реализовали решение

  5. Получили поддержку независимых пространств

  6. Схема основана только на разрешающих правах (нет запрещающих)

Подводные камни:

  • При большой нагрузке может потребоваться кэширование и оптимизация

  • Если продукты интерпретируют права по-разному, может потребоваться унификация

Рекомендации:

  • Точечный доступ нужно выбирать с умом и оценивать необходимость

  • Поймите, кому действительно нужно редактировать права

  • Т.к. права в целом штука редко меняющаяся (конкретно в нашем проекте именно так), то такие вещи стоит денормализовать по месту (набор разрешений для фронтенда)

  • Фильтрация данных должна выполняться на уровне целевых API

  • Подумайте заранее как фронтенду будут отдаваться разрешения (лучше отдавать флажки, фронтенд хорошо умеет с ними работать)

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

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

Комментарии (0)