Passwordless ElasticSearch

Заметки вокруг cloud.elastic.co

How it works?

За для удобства, рассматриваем эластик как rest api сервис, как если бы это было, не знаю, наше sms-api

Так вот, это самое api, по умолчанию закрыто за basic auth

Если бы это был dotnet то это было бы что то типа (псевдокод):

builder.Services.AddAuthentication().AddBasic(options => ...);

И как следствие запросы к нему мы пуляем вот так:

curl -u elastic:Secr3tW0rd -X POST https://elastic.com/vacancies/_search

По мимо этого у эластика есть еще ряд других опций, а именно:

  • api-key - авторизация через ключик, который формируется, ротируется и отзывается эластиком
  • saml - это как soap только про авторизацию
  • jwt - это то что мы будем рассматривать в этой заметке
  • oidc - это полноценная махина вокруг openid connect которая прикручена к kibana'е за для авторизации
  • и другие

В портале эластика, если провалиться к конретно взятому инстансу и кликнуть редактировать и провалиться в его свойства, можно добраться до конфигурационного yaml (стоит думать про это, как про appsettings.json)

Выглядит вот так:

image-20240926-103040.png

На практике, это означает, что если бы это был dotnet то мы бы сделали что то типа:

builder.Services.AddAuthentication()
  .AddBasic(...) // это штатная авторизация elastic:Secr3tW0rd
  .AddJwt(...)   // мы будем крутить эту штуку
  .AddOpenIdConnect(...) // это для кибаны

Все это дело, подключено и настроено в репо терраформа, пример для dev эластика можно посмотреть тут

За для интеграции с Azure, прямо там, мы создаем и настраиваем Azure App Registration, конфигурируем доступы и вот это все и следом, пробрасываем все эти client_id и прочую разность в эластик

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

How it works: JWT?

Итого, со стороны нашего rest api настроена JWT авторизация, а это значит, что, при наличии валидного JWT токена, мы должны мочь сходить в эластик и он должен нас пропустить

Далее пример как это проверить (предпологается что установлен az cli и выполнен az login)

Первым делом, проверяем что мы можем получить токен:

az account get-access-token --resource=api://elastic-dev --query accessToken -o tsv

Примечание: под капотом дергаются oidc и проче, за для сохранения целосности мозга, мы пока в эти дебри не залазим и пользуем az cli

image-20240926-103952.png

В токене важными будут:

  • aud - для кого выписывали
  • iss - кто выписывал
  • sub - пользователь

Со стороны эластика у нас прописаны соотв настройки, опять же, для простоты, на примере дотнета это было бы вот так:

builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
    .AddJwtBearer(options => {
        options.Authority = "https://sts.windows.net/695e64b5-2d13-4ea8-bb11-a6fda2d60c41";
        options.Audience = "api://elastic-dev";
    });

по сути эта настройка перепроверяет первых два поля и за счет issuer'а и jwks проверяет подпись

Имея такой токен, должно быть возможным выполнить следующее:

token=$(az account get-access-token --resource=api://elastic-dev --query accessToken -o tsv)
echo "token: $token"
curl "https://dev-b68b60.es.europe-west3.gcp.cloud.es.io" -H "Authorization: Bearer $token"

И оно должно будет нас пропустить и вернуть нам ответ

How to: dotnet?

Со стороны дотнета у нас уже есть заготовка которую делали под будущие эластики для сервисов

Если совсем кратко, выглядеть оно будет вот так:

using Azure.Core;
using Azure.Identity;
using Elastic.Clients.Elasticsearch;
using Elastic.Transport;

// dotnet add package Elastic.Clients.Elasticsearch
// dotnet add package Azure.Identity

var builder = WebApplication.CreateBuilder(args);
builder.Services.AddSingleton(_ = new DefaultAzureCredential());
builder.Services.AddSingleton(provider => new ElasticsearchClient("dev:ZXVyb3BlLXdlc3QzLmdjcC5jbG91ZC5lcy5pbzo0NDMkY2IwM2Q1ZDkzMGU5NDFmMWI0MzFjY2I2NjU0MjkwMWMkNjhhOGMxNDFjNmExNDk2NWEyMTI2YWUxOTM1MzkwMGU=", new OAuthAuthorizationHeader(provider.GetRequiredService<DefaultAzureCredential>())));

var app = builder.Build();
app.MapGet("/demo", async (ElasticsearchClient elasticsearchClient, CancellationToken cancellationToken) => {
  // await elasticsearchClient.SearchAsync(...)
  return "TODO";
});
app.Run();

public class OAuthAuthorizationHeader(DefaultAzureCredential defaultAzureCredential) : AuthorizationHeader
{
  public override bool TryGetAuthorizationParameters(out string value)
  {
    var accessToken = defaultAzureCredential.GetToken(new TokenRequestContext(["api://elastic-dev/.default"]));
    value = accessToken.Token;
    return !string.IsNullOrWhiteSpace(value);
  }
  public override string AuthScheme => "Bearer";
}

Аргумент cloudId в конструкторе принимает киллометровую строку из портала эластка

How to: terraform?

Перед тем как это деплоить понадобиться ряд дополнительных приседаний, в частности нам нужна свервисная учетка, от имени которой мы будем подключаться (читай то же самое как мы делаем для azure storage и sql)

Соотв, если таковой еще нет, в репо терраформа, в соотв папке заводим файлик, аля:

project.mactemp.tf

resource "azurerm_resource_group" "mactemp" {
  name     = "mactemp-rg-${var.suffix}"
  location = var.location
  tags = {
    "owner"      = "alexandrm@rabota.ua"
    "repository" = "https://github.com/rabotaua/terraform"
  }
}

resource "azurerm_user_assigned_identity" "mactemp" {
  name                = "mactemp-${var.user_assigned_identity_prefix}"
  location            = azurerm_resource_group.mactemp.location
  resource_group_name = azurerm_resource_group.mactemp.name
  tags = {
    "owner"      = "alexandrm@rabota.ua"
    "repository" = "https://github.com/rabotaua/terraform"
  }
}

resource "azurerm_federated_identity_credential" "mactemp" {
  name                = "mactemp"
  resource_group_name = azurerm_resource_group.mactemp.name
  issuer              = azurerm_kubernetes_cluster.kube.oidc_issuer_url
  parent_id           = azurerm_user_assigned_identity.mactemp.id
  audience            = ["api://AzureADTokenExchange"]
  subject             = "system:serviceaccount:${var.namespace}:mactemp"
}

data "azuread_service_principal" "elastic-dev" {
  display_name = "elastic-dev"
}

resource "azuread_app_role_assignment" "elastic-dev-mactemp" {
  app_role_id         = "00000000-0000-0000-0000-000000000000"
  resource_object_id  = data.azuread_service_principal.elastic-dev.object_id
  principal_object_id = azurerm_user_assigned_identity.mactemp.principal_id # "639e25b3-2c13-4d8d-94cf-0eff86dd5e46" # Object (principal) ID: az ad sp list --display-name mactemp-app-mid-eun-dev --query "[].{id:id}" -o tsv
}

resource "elasticstack_elasticsearch_security_role" "mactemp" {
  name = "mactemp"
  indices {
    names      = ["mactemp"]
    privileges = ["all"]
  }
  indices {
    names      = ["vacancysearch"]
    privileges = ["read"]
  }
}

resource "elasticstack_elasticsearch_security_role_mapping" "mactemp" {
  name  = "mactemp"
  roles = ["mactemp"]
  rules = jsonencode({
    all = [
      { field = { "realm.name" = "jwt2" } },
      { field = { "username" = azurerm_user_assigned_identity.mactemp.principal_id } } // "639e25b3-2c13-4d8d-94cf-0eff86dd5e46" } }
    ]
  })
}

resource "kubernetes_service_account" "mactemp" {
  metadata {
    name      = "mactemp"
    namespace = var.namespace
    labels = {
      app                           = "mactemp"
      "azure.workload.identity/use" = "true"
    }
    annotations = {
      "owner"                             = "alexandrm@rabota.ua"
      "repository"                        = "https://github.com/rabotaua/terraform"
      "azure.workload.identity/client-id" = azurerm_user_assigned_identity.mactemp.client_id
      "azure.workload.identity/tenant-id" = azurerm_user_assigned_identity.mactemp.tenant_id
    }
  }
}

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

Кроме двух новых ресурсов:

  • elasticstack_elasticsearch_security_role - имя роли и список индексов к которым эта роль имеет доступ
  • elasticstack_elasticsearch_security_role_mapping - маппим нашу учетку на эту роль

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

пожалуй кроме вопроса - нафига?!

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

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

в остальном - так надо было сделать вообще с самого начала, и вероятно так бы и сделали, эсли бы с этого начала существовало как класс