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)
Выглядит вот так:
На практике, это означает, что если бы это был 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
В токене важными будут:
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, что явно не ок
а раз нам все равно нужно делать это и учитывая то что в эластике свалка индексов ничейных было бы не плохо сразу разграничить все это дело, дабы навести трохи порядка, обезоапаситься от случайных удалений или доступов куда не надо, чему не надо
в остальном - так надо было сделать вообще с самого начала, и вероятно так бы и сделали, эсли бы с этого начала существовало как класс