Перейти к основному содержимому

Авторизация Spring Security с OPA

· 8 мин. чтения

1. Введение

В этом руководстве мы покажем, как передать решения об авторизации Spring Security в OPA — Open Policy Agent .

2. Преамбула: случай внешней авторизации

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

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

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

./df98d8a5b2bebeeb4a371ffb57346052.png

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

3. Что такое ОПА?

Open Policy Agent, или сокращенно OPA, представляет собой механизм оценки политик с открытым исходным кодом, реализованный в Go . Первоначально он был разработан Styra , а теперь является дипломированным проектом CNCF. Вот список некоторых типичных применений этого инструмента:

  • Фильтр авторизации посланника
  • Контроллер допуска Kubernetes
  • Оценка плана Terraform

Установить OPA довольно просто: просто скачайте двоичный файл для нашей платформы, поместите его в папку в PATH операционной системы, и все готово. Мы можем убедиться, что он правильно установлен с помощью простой команды:

$ opa version
Version: 0.39.0
Build Commit: cc965f6
Build Timestamp: 2022-03-31T12:34:56Z
Build Hostname: 5aba1d393f31
Go Version: go1.18
Platform: windows/amd64
WebAssembly: available

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

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

4. Написание политик

Вот как выглядит простая политика авторизации, написанная на REGO:

package foreach.auth.account

# Not authorized by default
default authorized = false

authorized = true {
count(deny) == 0
count(allow) > 0
}

# Allow access to /public
allow["public"] {
regex.match("^/public/.*",input.uri)
}

# Account API requires authenticated user
deny["account_api_authenticated"] {
regex.match("^/account/.*",input.uri)
regex.match("ANONYMOUS",input.principal)
}

# Authorize access to account
allow["account_api_authorized"] {
regex.match("^/account/.+",input.uri)
parts := split(input.uri,"/")
account := parts[2]
role := concat(":",[ "ROLE_account", "read", account] )
role == input.authorities[i]
}

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

Далее мы определяем фактические правила политики:

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

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

  • Операторы формы a := b или a=b являются простыми присваиваниями ( хотя они не совпадают )
  • Утверждения вида a = b {…условия} или a{…условия} означают «присвоить b значению a, если условия истинны ».
  • Внешний вид заказа в политическом документе не имеет значения

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

5. Оценка политик

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

{
"input": {
"principal": "user1",
"authorities": ["ROLE_account:read:0001"],
"uri": "/account/0001",
"headers": {
"WebTestClient-Request-Id": "1",
"Accept": "application/json"
}
}
}

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

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

$ opa run  -w -s src/test/rego

Опция -s включает работу в режиме сервера, а -w включает автоматическую перезагрузку файла правил. src / test/rego — это папка, содержащая файлы политики из нашего примера кода. После запуска OPA будет прослушивать запросы API на локальном порту 8181. При необходимости мы можем изменить порт по умолчанию, используя параметр -a .

Теперь мы можем использовать curl или другой инструмент для отправки запроса:

$ curl --location --request POST 'http://localhost:8181/v1/data/foreach/auth/account' \
--header 'Content-Type: application/json' \
--data-raw '{
"input": {
"principal": "user1",
"authorities": [],
"uri": "/account/0001",
"headers": {
"WebTestClient-Request-Id": "1",
"Accept": "application/json"
}
}
}'

Обратите внимание на часть пути после префикса /v1/data: она соответствует имени пакета политики, в котором точки заменены косой чертой .

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

{
"result": {
"allow": [],
"authorized": false,
"deny": []
}
}

Свойство результата — это объект, содержащий результаты, полученные механизмом политики. Мы видим, что в этом случае авторизованное свойство имеет значение false . Мы также можем видеть, что разрешать и запрещать — пустые массивы. Это означает, что входным данным не соответствует ни одно конкретное правило. В результате не совпало и основное авторизованное правило.

6. Интеграция диспетчера авторизации Spring

Теперь, когда мы увидели, как работает OPA, мы можем двигаться дальше и интегрировать его в структуру авторизации Spring. Здесь мы сосредоточимся на его реактивном веб-варианте, но общая идея применима и к обычным приложениям на основе MVC .

Во-первых, нам нужно реализовать bean-компонент ReactiveAuthorizationManager , который использует OPA в качестве своего бэкэнда:

@Bean
public ReactiveAuthorizationManager<AuthorizationContext> opaAuthManager(WebClient opaWebClient) {

return (auth, context) -> {
return opaWebClient.post()
.accept(MediaType.APPLICATION_JSON)
.contentType(MediaType.APPLICATION_JSON)
.body(toAuthorizationPayload(auth,context), Map.class)
.exchangeToMono(this::toDecision);
};
}

Здесь внедренный WebClient поступает из другого bean-компонента, где мы предварительно инициализируем его свойства из класса @ConfigurationPropreties .

Конвейер обработки делегирует методу toAuthorizationRequest сбор информации из текущего Authentication и AuthorizationContext , а затем создание полезной нагрузки запроса авторизации. Точно так же toAuthorizationDecision принимает ответ авторизации и сопоставляет его с AuthorizationDecision.

Теперь мы используем этот bean-компонент для создания SecurityWebFilterChain:

@Bean
public SecurityWebFilterChain accountAuthorization(ServerHttpSecurity http, @Qualifier("opaWebClient") WebClient opaWebClient) {
return http
.httpBasic()
.and()
.authorizeExchange(exchanges -> {
exchanges
.pathMatchers("/account/*")
.access(opaAuthManager(opaWebClient));
})
.build();
}

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

В нашем случае сам API /account представляет собой простую пару контроллер/служба, которая возвращает объект Account с поддельным балансом.

7. Тестирование

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

@Test
@WithMockUser(username = "user1", roles = { "account:read:0001"} )
void testGivenValidUser_thenSuccess() {
rest.get()
.uri("/account/0001")
.accept(MediaType.APPLICATION_JSON)
.exchange()
.expectStatus()
.is2xxSuccessful();
}

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

@Test
@WithMockUser(username = "user1", roles = { "account:read:0002"} )
void testGivenValidUser_thenUnauthorized() {
rest.get()
.uri("/account/0001")
.accept(MediaType.APPLICATION_JSON)
.exchange()
.expectStatus()
.isForbidden();
}

Наконец, давайте также проверим случай, когда аутентифицированный пользователь не имеет полномочий:

@Test
@WithMockUser(username = "user1", roles = {} )
void testGivenNoAuthorities_thenForbidden() {
rest.get()
.uri("/account/0001")
.accept(MediaType.APPLICATION_JSON)
.exchange()
.expectStatus()
.isForbidden();
}

Мы можем запустить эти тесты из IDE или из командной строки. Обратите внимание, что в любом случае мы должны сначала запустить сервер OPA, указав на папку, содержащую наш файл политики авторизации.

8. Заключение

В этой статье мы показали, как использовать OPA для экстернализации решений авторизации приложения на основе Spring Security. Как обычно, полный код доступен на GitHub .