Mandarin.Platform.Common 0.3.0

dotnet add package Mandarin.Platform.Common --version 0.3.0
                    
NuGet\Install-Package Mandarin.Platform.Common -Version 0.3.0
                    
This command is intended to be used within the Package Manager Console in Visual Studio, as it uses the NuGet module's version of Install-Package.
<PackageReference Include="Mandarin.Platform.Common" Version="0.3.0" />
                    
For projects that support PackageReference, copy this XML node into the project file to reference the package.
<PackageVersion Include="Mandarin.Platform.Common" Version="0.3.0" />
                    
Directory.Packages.props
<PackageReference Include="Mandarin.Platform.Common" />
                    
Project file
For projects that support Central Package Management (CPM), copy this XML node into the solution Directory.Packages.props file to version the package.
paket add Mandarin.Platform.Common --version 0.3.0
                    
#r "nuget: Mandarin.Platform.Common, 0.3.0"
                    
#r directive can be used in F# Interactive and Polyglot Notebooks. Copy this into the interactive tool or source code of the script to reference the package.
#:package Mandarin.Platform.Common@0.3.0
                    
#:package directive can be used in C# file-based apps starting in .NET 10 preview 4. Copy this into a .cs file before any lines of code to reference the package.
#addin nuget:?package=Mandarin.Platform.Common&version=0.3.0
                    
Install as a Cake Addin
#tool nuget:?package=Mandarin.Platform.Common&version=0.3.0
                    
Install as a Cake Tool

Mandarin.Platform.Common

Shared platform primitives for Mandarin .NET services: configuration (appsettings + OpenBao + Apollo), IdP auth, keyset cursor pagination, health endpoints, JSON logging with PII masking, unified error responses; PostgreSQL, Redis, RabbitMQ and notification-gateway client as separate packages.

Features

  • AddMandarinConfiguration() — appsettings.json / appsettings.{Env}.json + optional OpenBao and Apollo
  • UseOpenBao() / UseVault() — KV secrets; zero-trust default = Kubernetes SA JWT
  • OpenBao:SecretPaths — несколько путей KV, поздний перекрывает ранний (0.2.0)
  • OpenBao:CaCertPath / OpenBao:UseProxy — доп. CA и обход HTTP(S)_PROXY для хранилища (0.2.0)
  • KeyMap — rename secret keys → config keys (e.g. idp-client-secret → Idp:ClientSecret)
  • AddMandarinIdpAuth() — Self and/or App introspection
  • [IdpAuthorize(IdpPolicy.Self|App|Any)]
  • [IdpScope("<service>:<resource>.<verb>", ...)] — право на конкретную операцию: скоуп токена и право пользователя; несколько значений = «любое из»
  • IIdpTokenProvider / AddIdpClientCredentials("scope") — исходящие вызовы под токеном приложения (0.2.0)
  • AddIdpUserTokenPassThrough() — проброс Bearer пользователя в исходящий вызов (0.2.0)
  • AddBrowserClientsCors() — CORS-политика для SPA с чужого домена; origin'ы из конфига контура
  • Local mode — in Development / Testing / Local remote sources are off by default
  • Mandarin.Platform.Common.Pagination — keyset cursor / limit_to / filter_by (CursorCodec, PageCursor, SimpleFilterParser, PaginatedCollection<T>)
  • AddMandarinHealth() / MapMandarinHealth() - /health/live, /health/ready, /health/deps (0.3.0)
  • AddMandarinLogging() - Serilog JSON в stdout, ServiceName/Environment/TraceId/CorrelationId, маскирование ПДн и секретов (0.3.0)
  • AddMandarinErrors() / UseMandarinErrors() - единый ответ на исключения без стека и текста исключения (0.3.0)
  • AddMandarinNpgsql<TContext>() - пакет .Postgres: строка подключения из секции Database, миграции при старте под pg_advisory_lock (0.3.0)
  • AddMandarinRedis() - пакет .Redis: Endpoint или Sentinel (пароль только мастеру), IDistributedCache (0.3.0)
  • AddMandarinRabbitMq() - пакет .RabbitMq: подключения, топология с DLX/DLQ/retry, IRabbitPublisher, RabbitConsumer<T> (0.3.0)
  • AddNotificationGatewayClient() - пакет .NotificationGateway: отправка в notification-gateway по HTTP или RabbitMQ (0.3.0)

История изменений — CHANGELOG.md.

Пакеты (0.3.0)

Один репозиторий, один релиз, одна версия у всех пакетов. Тяжёлые зависимости вынесены в отдельные пакеты: сервису без БД не нужен EF Core, сервису без брокера - RabbitMQ.Client.

Пакет Что внутри Зависимости сверх ядра
Mandarin.Platform.Common конфигурация/OpenBao/Apollo, IdP, пагинация, CORS, health, логирование, ошибки Serilog.AspNetCore 8.0
Mandarin.Platform.Common.Postgres AddMandarinNpgsql<TContext>, миграции при старте, health postgres Npgsql.EntityFrameworkCore.PostgreSQL 8.0
Mandarin.Platform.Common.Redis AddMandarinRedis, Sentinel без пароля, IDistributedCache, health redis StackExchange.Redis 2.8, Microsoft.Extensions.Caching.StackExchangeRedis 8.0
Mandarin.Platform.Common.RabbitMq AddMandarinRabbitMq, топология, IRabbitPublisher, RabbitConsumer<T>, health rabbitmq RabbitMQ.Client 6.8 ([6.8.1, 7.0.0))
Mandarin.Platform.Common.NotificationGateway AddNotificationGatewayClient, INotificationGatewayClient пакет .RabbitMq

Health checks написаны в пакетах сами (PING / SELECT 1 / состояние соединения), без AspNetCore.HealthChecks.*: они проверяют то же ленивое соединение, которым пользуется сервис, и не тянут ещё по одному пакету на каждую зависимость.

Всё новое в 0.3.0 включается только явным вызовом: сервис, поднявший версию пакета без правок кода, ведёт себя как на 0.2.x (двоичная совместимость проверена ApiCompat против 0.2.1).

Usage

var builder = WebApplication.CreateBuilder(args);

// 1) Config
builder.AddMandarinConfiguration(c =>
{
    c.UseOpenBao(bao =>
    {
        bao.Map("idp-client-id", "Idp:ClientId");
        bao.Map("idp-client-secret", "Idp:ClientSecret");
    });
    // c.UseApollo(...);
});

// 2) IdP auth (explicit)
builder.Services.AddMandarinIdpAuth(auth =>
{
    auth.EnableSelfIntrospection();
    auth.EnableAppIntrospection();
});

// 3) Outgoing calls (optional)
builder.Services.AddHttpClient<ICustomersClient, CustomersClient>()
    .AddIdpClientCredentials("customers:documents.read customers:documents_all.read");

var app = builder.Build();
app.UseAuthentication();
app.UseAuthorization();

app.MapGet("/events", [IdpAuthorize(IdpPolicy.App)] (HttpContext http) =>
{
    var user = http.GetIdpUser();
    return Results.Ok(user?.UserId);
});

CORS для браузерных клиентов

builder.Services.AddBrowserClientsCors(builder.Configuration);
...
app.UseCors(BrowserClientsCors.PolicyName);
{
  "Cors": {
    "AllowedOrigins": ["https://broker-crm-k8s.mandarin.io"]
  }
}

В config.properties контура — Cors__AllowedOrigins__0, __1, …

Сначала проверь, нужна ли политика вообще. По ADR-0023 §6.1 разделяет не сервис, а потребитель: браузер НАШЕГО фронта должен ходить path'ом под origin'ом этого фронта — тогда CORS не нужен как класс. Политика нужна там, где к ручке ходит браузер из ЧУЖОГО фронта.

Дефолты осознанные, менять их не надо без причины:

что дефолт почему
AllowedOrigins пусто = политика не регистрируется «открыто, пока не настроили» — неверная сторона для ошибки
AllowedMethods GET, POST, PUT, PATCH, DELETE, OPTIONS пропущенный метод даёт 404 на preflight, а не понятный отказ
AllowedHeaders Authorization, Content-Type Authorization не «простой», без него preflight режет Bearer
PreflightMaxAgeHours 24 иначе preflight на каждый запрос удваивает походы

AllowAnyOrigin не поддерживается: токен защищает от чтения, но не от того, что чужая страница выполнит запрос в браузере сотрудника его же токеном. AllowCredentials не нужен — авторизация идёт Authorization: Bearer, а не кукой.

⚠️ Свойства-коллекции nullable намеренно. ConfigurationBinder дописывает значения из конфига к непустому дефолту массива, а не заменяет их — с дефолтом в инициализаторе свойства сузить список через конфиг было бы нельзя, и молча.

Пагинация (cursor / limit_to / filter_by)

Keyset-курсор, не offset. Контракт списка: query cursor, limit_to, filter_by; ответ { items, cursor: { count, total, next, prev } } (PaginatedCollection<T>).

Query Смысл
cursor непрозрачный token следующей/предыдущей страницы (CursorCodec)
limit_to размер страницы, 1…500, дефолт 50 (PageCursor.DefaultPageSize)
filter_by equality-фильтры key=value&key2=value2 (SimpleFilterParser)

Курсор — base64url(json(PageCursor)): якорь (Anchor UTC), тайбрейкер (TieId / TieKey), направление (Next / Previous) и снимок фильтров. Снимок нужен, чтобы не листать страницу с одними фильтрами, а потом подменить filter_by в следующем запросе. Битый token или несовместимые фильтры → BadCursorException → в API-хосте HTTP 400.

var filterBy = SimpleFilterParser.Canonicalize(request.FilterBy);
var page = CursorCodec.Decode(request.Cursor) ?? new PageCursor
{
    PageSize = request.LimitTo ?? PageCursor.DefaultPageSize,
    Filters = filterBy
};

// … keyset-запрос по page.Anchor / page.TieKey / page.Direction …

return new PaginatedCollection<T>
{
    Items = items,
    Cursor = new PaginationCursorResult
    {
        Count = items.Count,
        Total = total,
        Next = CursorCodec.Encode(nextPage),
        Prev = CursorCodec.Encode(prevPage)
    }
};

SimpleFilterParser.Parse — словарь (ключи case-insensitive). Canonicalize сортирует ключи и приводит их к lower-case, чтобы один и тот же набор фильтров давал один снимок в курсоре. MergeIntoFilterBy дописывает плоские пары поверх filter_by (пустой value не затирает ключ).

Это примитивы пакета, не middleware: хост сам читает query, ходит в БД keyset-ом и мапит BadCursorException на 400.

OpenBao section (appsettings)

{
  "OpenBao": {
    "Uri": "https://openbao.example",
    "Namespace": "optional-ns",
    "SecretPath": "platform/shared",
    "SecretPaths": ["platform/my-service"],
    "MountPath": "kv/data",
    "CaCertPath": "/etc/ssl/mandarin/openbao-ca.pem",
    "UseProxy": false,
    "Auth": {
      "Method": "Kubernetes",
      "Role": "my-service",
      "Mount": "kubernetes",
      "JwtPath": "/var/run/secrets/kubernetes.io/serviceaccount/token",
      "Audience": "openbao.vdc004"
    },
    "KeyMap": {
      "idp-client-id": "Idp:ClientId",
      "idp-client-secret": "Idp:ClientSecret"
    }
  },
  "Idp": {
    "Authority": "https://accounts.mandarin.io/"
  }
}

Bootstrap credentials (SA JWT / Token / AppRole SecretId) are never committed. Token and AppRole are explicit fallbacks (Auth.Method), not the zero-trust default.

Несколько путей KV (SecretPaths)
Правило Что значит
Порядок сначала SecretPath, затем SecretPaths по порядку; повторы читаются один раз
Перекрытие ключ из пути, указанного позже, перекрывает ранний
Конфликт разные значения одного ключа → warning в лог (имя ключа и оба пути, без значений)
KeyMap применяется к каждому пути отдельно, до слияния
Обязательность нужен хотя бы один путь — SecretPath или SecretPaths

Конфигурация собирается до DI, поэтому предупреждения по умолчанию идут в stderr (в k8s — тот же поток логов пода); свой логгер можно передать через bao.Logger = .... Выдать сервису доступ к путям — задача политики в OpenBao, библиотека этим не занимается.

TLS и прокси
  • CaCertPath — PEM-файл (один или несколько сертификатов), которому доверять дополнительно к системным корням. Сертификат, прошедший обычную проверку, принимается как раньше; иначе цепочка строится с доверием только к этим CA. Несовпадение имени хоста не прощается никогда. Файла нет — падение на старте с понятным сообщением.
  • UseProxy — по умолчанию false: к хранилищу всегда напрямую, мимо HTTP(S)_PROXY. Нужен там, где в поде выставлен egress-прокси для внешних API, а OpenBao — внутренний адрес.
  • Обе настройки применяются и к login (auth/kubernetes, auth/approle), и к чтению секретов.

Health check для OpenBao библиотека не добавляет: секреты читаются на старте, и недоступное хранилище и так роняет под (fail-fast).

IdP policies

Attribute Flow
[IdpAuthorize(IdpPolicy.Self)] Bearer → GET self_introspection
[IdpAuthorize(IdpPolicy.App)] client_credentials → POST /oauth/introspect/
[IdpAuthorize(IdpPolicy.Any)] Self or App

See samples/HybridIdpAuth.

Опции Idp
Ключ Умолчание Что делает
Authority, ClientId, ClientSecret — адрес IdP и личность сервиса (входящая интроспекция и исходящие токены)
Scope introspection scope токена приложения для App-интроспекции
TokenEndpoint / IntrospectEndpoint / SelfIntrospectionEndpoint /oauth/token/ / /oauth/introspect/ / /api/v1/users/self_introspection пути IdP
ScopeCheck ScopeAndPermission строгость [IdpScope], см. ниже
AllSuffixImplies false svc:res_all.verb покрывает svc:res.verb
IntrospectionCacheSeconds 0 (выкл.) кэш успешной интроспекции по SHA-256 токена
UnavailableAs503 false недоступный IdP → 503 вместо 401
ErrorFormat ProblemDetails тело 401/403/503: ProblemDetails или Legacy ({"error": "..."})

Все умолчания повторяют поведение 0.1.x.

Совместимость со старым конфигом. Если Idp:Authority / Idp:ClientId / Idp:ClientSecret пусты, а есть OAuth2:Authority / OAuth2:Introspection:ClientId / OAuth2:Introspection:ClientSecret, значения берутся оттуда (каждое поле отдельно) с warning в лог. Это мост на время переезда, а не постоянная схема: конфиг контура стоит перевести на секцию Idp.

Кэш интроспекции (IntrospectionCacheSeconds > 0). Ключ — SHA-256 токена, сам токен в памяти не хранится. Кэшируется только успех; срок записи дополнительно ограничен exp токена, если IdP его вернул. Цена: отзыв токена виден сервису с задержкой до IntrospectionCacheSeconds.

Недоступный IdP (UnavailableAs503 = true). Сетевая ошибка, таймаут или 5xx от IdP — ответ 503 и LogError, а не 401. Разница важна клиенту: на 401 он выбрасывает токен и идёт на перелогин, на 503 — повторяет позже. 4xx от IdP («токен плохой») остаётся 401.

Формат ошибок (ErrorFormat). Касается ответов [IdpScope] (401/403) и challenge схем (401/503). В ProblemDetails пустой 401 от challenge сохранён как в 0.1.x; в Legacy тело {"error": "unauthorized"}.

Кто вызывает: IdpUser
Свойство Откуда
UserId user.user_id, иначе числовой client_id; для приложения с нечисловым client_id — 0
ClientId user.client_id, иначе client_id верхнего уровня; строкой
Email user.email
Scopes / Perms см. ниже

До 0.2.0 токен приложения с нечисловым client_id (например, settings-gateway) получал 401: JsonElement.TryGetInt64 на строке бросает исключение, и обработчик аутентификации превращал его в отказ. Теперь такой вызывающий опознаётся по ClientId; в claims — client_id, а NameIdentifier = ClientId, если UserId == 0.

Права на операцию ([IdpScope])

[IdpAuthorize] отвечает только на вопрос «валиден ли токен» — то есть пропускает любой валидный токен любого сервиса. Для ручек записи этого мало: нужен гейт на конкретную операцию.

[IdpAuthorize(IdpPolicy.App)]          // 401: кто это
public sealed class InboxController : ControllerBase
{
    [IdpScope("task-router:inbox.write")]   // 403: можно ли ему ЭТО
    public async Task<ActionResult> Push(...) { }

    [IdpScope("customers:documents.read", "customers:documents_all.read")]   // любое из
    public async Task<ActionResult> Read(...) { }
}

Право считается выданным, только если строка присутствует в обоих списках ответа интроспекции:

Список Где в ответе IdP Что значит
IdpUser.Scopes scope верхнего уровня, через пробел что разрешено ЭТОМУ токену
IdpUser.Perms perms внутри объекта user что в принципе разрешено пользователю

Пересечение считается здесь, а не в IdP, потому что IdP отдаёт perms неотфильтрованными: код фильтрации в нём написан, но закомментирован до готовности потребителей. Одного скоупа мало (право могли не выдать или отозвать), одного права мало (иначе токен, выписанный под чтение, молча получил бы все права своего владельца).

Сравнение точное: подстрочное пропускало бы …inbox.write.audit как …inbox.write.

Форма имени — <сервис>:<ресурс>.<глагол>, как в реестре IdP (coreapi:invoice.read, clients:client_services_all.write).

Строгость настраивается

Состав скоупов и прав у сервисов разный. Там, где права пользователям не заводятся вовсе, жёсткое пересечение оставило бы выбор «или 403 всем, или совсем без гейта» — поэтому режим переключается:

builder.Services.AddMandarinIdpAuth(auth => auth
    .EnableAppIntrospection()
    .Configure(o => o.ScopeCheck = ScopeCheckMode.ScopeOnly));

или из конфигурации — Idp:ScopeCheck.

Режим Что требуется
ScopeAndPermission (умолчание) скоуп токена и право пользователя
ScopeOnly только скоуп токена
PermissionOnly (0.2.0) только право пользователя — для сервисов, которые исторически проверяли одни perms
ScopeOrPermission (0.2.0) скоуп или право — семантика settings-gateway (Permissions.Validate(x) \|\| Scope.Validate(x))

Умолчание строгое намеренно: ослабление должно быть осознанным действием, а не тем, что досталось молча при обновлении пакета. PermissionOnly и ScopeOrPermission слабее умолчания — это переходные режимы для сервисов, переезжающих без изменения поведения.

Idp:AllSuffixImplies = true добавляет правило «широкое покрывает узкое»: customers:documents_all.read в списке засчитывается за customers:documents.read. Обратное неверно, глагол должен совпадать. Применяется к обоим спискам в любом режиме.

Что эта версия НЕ меняет для существующих потребителей

[IdpScope] — атрибут, который не действует, пока его не поставили. Эндпоинты с одним [IdpAuthorize] ведут себя ровно как в 0.1.2: разбор scope/user.perms чисто аддитивный, права ни у кого не проверяются задним числом. Конструкторы IdpUser 0.1.x и IdpScopeAttribute(string) сохранены ради двоичной совместимости.

Исходящие вызовы

Токен приложения (client_credentials)
// Только исходящие (сервис сам токены не принимает):
builder.Services.AddMandarinIdpClientCredentials();

// На конкретный HttpClient — заголовок проставится сам:
builder.Services.AddHttpClient<IContextGraphClient, ContextGraphClient>()
    .AddIdpClientCredentials("context-graph:events.write");

// Или руками:
public sealed class Foo(IIdpTokenProvider tokens)
{
    async Task CallAsync(CancellationToken ct)
    {
        var token = await tokens.GetTokenAsync("customers:customers_all.read", ct);
        ...
    }
}

IIdpTokenProvider регистрируется и внутри AddMandarinIdpAuth — у сервиса одна личность в IdP (Idp:ClientId/ClientSecret), та же, что проверяет входящие токены.

Свойство Как
Кэш IMemoryCache, отдельно на каждый scope; «a b» и «b a» — одна запись
Обновление за 60 с до expires_in (IdpTokenProvider.RefreshBefore)
Параллельность один поход в IdP на scope (семафор на ключ); разные scope друг друга не ждут
Время жизни singleton; HttpClient берётся из IHttpClientFactory на каждый вызов
Ошибка IdP HttpRequestException с StatusCode — вызывающий уходит в ретрай, а не молча без токена

Явно выставленный вызывающим заголовок Authorization handler не трогает.

Проброс токена пользователя
builder.Services.AddHttpClient<ISettingsClient, SettingsClient>()
    .AddIdpUserTokenPassThrough();                               // только токен пользователя

builder.Services.AddHttpClient<ISettingsClient, SettingsClient>()
    .AddIdpUserTokenPassThrough("settings:settings.read");       // + фолбэк для воркеров

builder.Services.AddHttpClient<ISettingsClient, SettingsClient>()
    .AddIdpUserTokenPassThrough(o =>
    {
        o.FallbackScope = "settings:settings.read";              // GET/HEAD/OPTIONS
        o.FallbackWriteScope = "settings:settings.write";        // остальные методы
    });

Семантика повторяет TokenPassThroughHandler из settings-gateway:

Ситуация Что уходит
во входящем запросе есть Bearer он же
HTTP-контекста нет (фоновый воркер) токен приложения по fallback-scope; без него — IdpTokenPassThroughException
HTTP-контекст есть, Bearer нет отказ — IdpTokenPassThroughException (HttpRequestException, StatusCode = 403)
AlwaysApplicationToken = true всегда токен приложения

Третья строка — главное: запрос пользователя без токена НЕ уходит под токеном приложения. Такой фолбэк давал бы вызов с правами шире, чем у самого пользователя. Проверку «есть ли у пользователя право на эту операцию» handler не делает — это работа [IdpScope] на входе.

Health: /health/live, /health/ready, /health/deps (0.3.0)

builder.Services.AddMandarinHealth(o => o.LegacyAlias = LegacyHealthAlias.Live)   // IHealthChecksBuilder
    .AddCheck<IdpReachableCheck>("idp", tags: [MandarinHealthTags.External]);
...
app.MapMandarinHealth();
Путь Что проверяет Для чего
/health/live только процесс (встроенная проверка self, тег live) livenessProbe
/health/ready проверки с тегом ready: своя БД, свой брокер readinessProbe
/health/deps всё, включая чужие сервисы (тег external) людям и мониторингу, не для проб

Почему так: зависимость в liveness рестартит все поды разом, когда лежит БД, и рестарт её не лечит. Недоступный сосед (IdP, чужой API) - не повод выводить из балансировки все поды сервиса.

Проверки postgres (тег ready), redis и rabbitmq (тег ready по опции) регистрируют пакеты .Postgres/.Redis/.RabbitMq. Свои проверки - через возвращаемый IHealthChecksBuilder с нужным тегом.

Ответ:

{"status":"Unhealthy","checks":[{"name":"postgres","status":"Unhealthy","durationMs":15,"error":"database is unreachable (NpgsqlException)"},{"name":"self","status":"Healthy","durationMs":0}]}

error - Description проверки или имя типа исключения; текст исключения не выводится никогда (драйверы кладут туда адреса и имена пользователей). Коды: Healthy/Degraded - 200, Unhealthy - 503.

Совместимость. Старый /health по умолчанию не регистрируется - какой смысл он имел в сервисе, знает только сервис. Алиас задаётся явно: LegacyAlias = Live | Ready в коде или Health:LegacyAlias в конфиге. Пути настраиваются (Health:LivePath, ReadyPath, DepsPath). Эндпоинты анонимные.

livenessProbe:  { httpGet: { path: /health/live,  port: http } }
readinessProbe: { httpGet: { path: /health/ready, port: http } }

Логирование: Serilog JSON + маскирование ПДн (0.3.0)

builder.AddMandarinLogging(o => o.ServiceName = "customers");
...
app.UseMandarinRequestLogging(o => o.LogRequestBody = true);   // тело - только по опции
  • JSON в stdout, RenderedCompactJsonFormatter - как уже пишут сервисы платформы.
  • Уровни: секция Serilog (MinimumLevel, Override, свои WriteTo), а если её нет - переводятся из Logging:LogLevel (Default → минимальный, остальные ключи → Override). Есть Serilog:WriteTo - свой stdout-sink библиотека не добавляет (строки не задваиваются).
  • Свойства каждого события: ServiceName (опция → Observability:ServiceName → OTEL_SERVICE_NAME → имя приложения), Environment, TraceId/SpanId текущей Activity, CorrelationId.
  • CorrelationId: из входящего X-Correlation-Id, затем X-Request-Id (не длиннее 128 символов, только [A-Za-z0-9-_.:]), иначе TraceIdentifier. Возвращается в ответе заголовком X-Correlation-Id. Middleware ставится первым в пайплайне сам (IStartupFilter); выключить - o.AddCorrelationMiddleware = false и app.UseMandarinCorrelation(). Консьюмеры RabbitMQ выставляют его из свойства/заголовков сообщения, публикатор - проставляет.

Маскирование - по ИМЕНАМ ключей, последним enricher'ом перед sink'ом. Значение заменяется на ***.

Что Ключи (регистр и разделители не важны)
паспорт *passport*, issued_by, department_code, division_code; series+number рядом в одном объекте
ИНН, СНИЛС inn, *_inn, *snils*
контакты *phone*, msisdn, mobile, *email*, *address* (кроме *ip_address, mac_address)
дата рождения *birth*, dob
секреты *password*, pwd, *secret*, *token*, *api_key*, *authorization*, *cookie*, *credential*
карта *card_number*, pan, cvv, cvc

Где работает: скалярные свойства ({Phone}), объекты {@Passport} на любой глубине (и целиком, если имя типа чувствительное: PassportDto), словари, коллекции, JsonNode/JsonElement, и строки с JSON - logger.LogInformation("{Body}", JsonSerializer.Serialize(passport)) тоже маскируется по ключам внутри. В обычном тексте маскируются password=..., token=..., client_secret=..., Authorization: Bearer ... (query string, form-body). События Microsoft.Hosting.Lifetime не трогаются ({address} там - адрес Kestrel).

Расширение из конфига:

{
  "LogMasking": {
    "Keys": ["fio", "*contract_number*"],
    "ExcludeKeys": ["email_verified"],
    "MaxJsonLength": 65536
  }
}

UseMandarinRequestLogging не сломан: вызов без аргументов работает как в 0.2.x (одна строка на запрос, успешные /health не пишутся). С AddMandarinLogging он берёт CorrelationId, выставленный middleware (то есть входящий), а без него - TraceIdentifier, как раньше. Новая перегрузка UseMandarinRequestLogging(o => ...): SkipSuccessfulPaths (по умолчанию /health и /health/live|ready|deps), LogRequestBody (по умолчанию false), MaxBodyLength (4096), BodyContentTypes. Тело буферизуется (дальше по пайплайну читается как обычно) и маскируется до записи - JSON по ключам, form-urlencoded по именам полей.

Ошибки: единый ответ на исключения (0.3.0)

builder.Services.AddMandarinErrors(o => o
    .Map<InsufficientFundsException>(StatusCodes.Status409Conflict, "insufficient_funds", exposeMessage: true));
...
app.UseMandarinErrors();   // первым в пайплайне

Необработанное исключение → статус по маппингу, машинный code, traceId. Ни стека, ни Exception.Message наружу (кроме маппингов с exposeMessage: true).

Исключение Статус code
ArgumentException (и наследники), ValidationException (DataAnnotations и любой тип с таким именем, например FluentValidation) 400 bad_request / validation_error
BadCursorException 400 bad_cursor
BadHttpRequestException его статус (400, 413, ...) bad_request
UnauthorizedAccessException, IdpTokenPassThroughException 403 forbidden
KeyNotFoundException, любой *NotFoundException 404 not_found
IdpUnavailableException 503 service_unavailable
OperationCanceledException при оборванном клиентом запросе 499, без тела и без Error в логе -
всё остальное 500 internal_error

o.Map<TException>(status, code) перекрывает встроенное; из подходящих выбирается ближайший по иерархии тип. 5xx пишутся в лог Error со стеком, 4xx из маппинга - Information (LogClientErrorsAsWarning = true - Warning с исключением).

Формат - тот же, что у Idp:ErrorFormat:

// ProblemDetails (умолчание), application/problem+json
{"title":"Internal Server Error","status":500,"code":"internal_error","traceId":"4bf92f3577b34da6a3ce929d0e0e4736"}
// Legacy
{"error":"internal_error","trace_id":"4bf92f3577b34da6a3ce929d0e0e4736"}

Порядок выбора формата: o.Format в коде → Errors:Format → Idp:ErrorFormat → ProblemDetails. Сервису, у которого уже стоит Idp:ErrorFormat = Legacy, отдельно настраивать ничего не нужно. Обработчик зарегистрирован и как IExceptionHandler - работает и со стандартным app.UseExceptionHandler(...).

PostgreSQL: Mandarin.Platform.Common.Postgres (0.3.0)

builder.Services.AddMandarinNpgsql<OrdersDbContext>(builder.Configuration, o =>
{
    o.SectionName = "Database";
    o.MigrateOnStart = true;
    // o.EnableRetryOnFailure = true;
    // o.ConnectionStringName = "OrdersDb";   // фолбэк на ConnectionStrings:OrdersDb
});
{
  "Database": { "Host": "orders-db.internal", "Port": 5433, "Name": "orders", "User": "orders" },
  "OpenBao": { "KeyMap": { "postgres-password": "Database:Password" } }
}
  • Строка подключения собирается NpgsqlConnectionStringBuilder-ом (пароль с ;/= ничего не ломает) из Host, Port (5432), Name|Database, User|Username, Password; пароль - Database:Password, иначе ключ o.PasswordKey (например, POSTGRES_PASSWORD из env).
  • Умолчания: Timeout 15 с, CommandTimeout 30 с, KeepAlive 30 с, MaxPoolSize 20 (не 100: max_connections общего PostgreSQL делится между всеми репликами всех арендаторов), ApplicationName = имя сборки. Переопределяются ключами секции (MaxPoolSize, SslMode, SearchPath, ...).
  • Фолбэк ConnectionStrings:<ConnectionStringName>: строка как есть, умолчания дописываются только для не заданных в ней ключей, пароль - только если его в строке нет.
  • Конфиг неполный - падение при регистрации с именем недостающего ключа; значение пароля не попадает ни в одно сообщение. Для логов - MandarinNpgsqlConnectionString.Redact(cs).
  • MigrateOnStart: hosted service, в WebApplication стартует ДО Kestrel - под не принимает трафик на старой схеме. Реплики сериализуются через pg_try_advisory_lock (ключ - от имени контекста): мигрирует одна, остальные ждут и видят, что миграций не осталось. Всё вместе ограничено MigrationTimeout (5 мин) - дальше старт падает с «did not finish within ...»; ошибка миграции - InvalidOperationException без строки подключения (детали во внутреннем исключении).
  • Health check postgres (SELECT 1, тег ready); o.HealthCheck = false - без него.
  • EnableRetryOnFailure выключен по умолчанию: с ним свои транзакции надо оборачивать в execution strategy.

Redis: Mandarin.Platform.Common.Redis (0.3.0)

builder.Services.AddMandarinRedis(builder.Configuration, sectionName: "Redis", o =>
{
    o.AddDistributedCache = true;      // IDistributedCache на том же мультиплексоре
    o.InstanceName = "customers:";
    // o.HealthCheckReady = true;      // только если без Redis сервис не работает
});
{
  "Redis": {
    "Sentinels": ["10.66.4.31:26379", "10.66.4.32:26379", "10.66.4.33:26379"],
    "ServiceName": "platform",
    "Database": 5
  },
  "OpenBao": { "KeyMap": { "redis-password": "Redis:Password" } }
}

Режимы: Endpoint (host:port) или Sentinels + ServiceName (по умолчанию platform); фолбэк - ConnectionString StackExchange.Redis целиком. Database - номер из реестра арендаторов (без него - общая база 0). abortConnect=false, ConnectTimeoutMs/SyncTimeoutMs 5000, AsyncTimeoutMs = sync.

Пароль в режиме Sentinel уходит только мастеру. На dl-stage у Sentinel пароля нет, у мастера есть. Разбор StackExchange.Redis 2.8 (ConnectionMultiplexer.Sentinel.cs, ServerEndPoint.cs, CommandMap.cs): ConnectionMultiplexer.Connect(options) с заданным ServiceName уходит в SentinelPrimaryConnect(options), который открывает соединение к Sentinel с ТЕМ ЖЕ options; рукопожатие шлёт AUTH <Password> любому серверу, если пароль не пуст и команда есть в CommandMap, а CommandMap.Sentinel содержит auth. Sentinel без requirepass отвечает ERR AUTH ... called without any password configured, клиент помечает соединение как auth-suspect (и сообщения о любых последующих сбоях начинают указывать на пароль); Sentinel с другим паролем - WRONGPASS и отказ. Поэтому библиотека строит ДВА набора настроек:

  • для Sentinel - те же адреса, пароль SentinelPassword (пусто - AUTH не шлётся вовсе), без пользователя и базы → ConnectionMultiplexer.SentinelConnect(...);
  • для мастера - ServiceName, Password, Database → sentinelConnection.GetSentinelMasterConnection(masterOptions): адрес мастера спрашивается у Sentinel, переключение при failover отслеживается подпиской на +switch-master.

Если на Sentinel включат requirepass - задать Redis:SentinelPassword.

Подключение ленивое: IRedisConnectionProvider.GetConnectionAsync() подключается при первом обращении, неудача не кэшируется навсегда - следующая попытка после паузы (1 с, растёт до 30 с). Процесс на старте не падает. IConnectionMultiplexer тоже зарегистрирован (singleton, ждёт подключения при первом разрешении; в фоновом коде лучше брать провайдер). Health check redis (PING) по умолчанию без тега ready - Redis чаще всего кэш.

RabbitMQ: Mandarin.Platform.Common.RabbitMq (0.3.0)

builder.Services.AddMandarinRabbitMq(builder.Configuration, "RabbitMQ", b => b
    .DeclareExchange("orders", "topic")
    .DeclareQueue("orders.created", q => { q.DeadLetter = true; q.RetryDelayMs = 30_000; })
    .Bind("orders", "orders.created", "order.created")
    .AddConsumer<OrderCreatedHandler, OrderCreated>("orders.created", configure: c =>
    {
        c.PrefetchCount = 20;
        c.FailureMode = RabbitFailureMode.Retry;
        c.MaxRetries = 3;
    })
    .Configure(o => o.HealthCheckReady = true));

public sealed class OrderCreatedHandler(IOrders orders) : IRabbitMessageHandler<OrderCreated>
{
    public Task HandleAsync(OrderCreated message, RabbitMessageContext context, CancellationToken ct)
        => orders.ProcessAsync(message.OrderId, ct);
}

// публикация
await publisher.PublishAsync("default", "orders", "order.created", new OrderCreated(42),
    headers: new Dictionary<string, object?> { ["x-source"] = "customers" }, ct);
{
  "RabbitMQ": {
    "Connections": {
      "default": { "Host": "rmq-dl-stage-01.internal", "Port": 5672, "VirtualHost": "customers", "UserName": "customers" },
      "notification-gateway": {
        "Host": "rmq-dl-stage-01.internal", "VirtualHost": "notification-gateway", "UserName": "customers",
        "Passive": true,
        "Queues": [ { "Name": "notification_gateway_send_event_queue" } ]
      }
    },
    "Naming": "SnakeCaseLower",
    "PublisherConfirms": true
  },
  "OpenBao": { "KeyMap": { "rabbitmq-password": "RabbitMQ:Connections:default:Password" } }
}

Плоская секция (RabbitMQ:Host, Port, VirtualHost, UserName, Password или Uri вида amqp://...) = подключение default. Повторные вызовы AddMandarinRabbitMq дополняют зарегистрированное.

  • Подключения: AutomaticRecovery + TopologyRecovery, heartbeat 30 с. Подключение ленивое, в фоне (RabbitConnectionWarmupService) с паузами 1 → 30 с: процесс стартует и живёт при недоступном брокере; консьюмеры ждут подключения сами. Пароль не попадает в логи и исключения (user@host:port/vhost).
  • Топология (код и/или конфиг: Exchanges, Queues, Bindings): объявляется при подключении и повторно после восстановления. DeadLetter = true - DLX <очередь>.dlx (direct) и DLQ <очередь>.dlq, аргументы x-dead-letter-* у очереди (имена настраиваются). RetryDelayMs - retry-очередь <очередь>.retry с x-message-ttl, из которой сообщение возвращается в основную. Аргументы из конфига приводятся к типам (x-message-ttl, x-max-length - int). Passive = true - только проверить, что exchange'и и очереди есть (*DeclarePassive), не создавая: для очередей, которые заводит владелец. Ошибка топологии не рвёт соединение, но видна в health check.
  • IRabbitPublisher: JSON (Naming: SnakeCaseLower - умолчание, как у сервисов платформы, CamelCase, PascalCase), persistent, content_type=application/json, message_id, correlation_id = текущий CorrelationId. PublisherConfirms (по умолчанию да): метод завершается после подтверждения брокера. Mandatory (по умолчанию да): немаршрутизируемое сообщение → RabbitUnroutableException, а не тихий успех. byte[]/string уходят как есть.
  • RabbitConsumer<TMessage> (BackgroundService) и AddConsumer<THandler, TMessage>(queue, connection): prefetch, ручной ack, scope на сообщение, CorrelationId в логах. Ошибка десериализации - сразу nack без requeue (в DLQ). Исключение обработчика: DeadLetter (умолчание) - nack без requeue; Retry - копия в retry-очередь с x-retry-count + 1 и ack оригинала, после MaxRetries - в DLQ. Остановка: basic.cancel, ожидание уже взятых сообщений до ShutdownTimeout (30 с); прерванное остановкой - nack с requeue. Канал, закрытый брокером при живом соединении, пересоздаётся.
  • Health check rabbitmq: соединения открыты, топология без ошибок; тег ready - HealthCheckReady.

Клиент notification-gateway: Mandarin.Platform.Common.NotificationGateway (0.3.0)

builder.Services.AddNotificationGatewayClient(builder.Configuration, "NotificationGateway");
...
await notifications.SendAsync(new NotificationRequest
{
    TemplateId = "broker-offer-signed",
    ClientId = app.ClientId,              // → variables.client_id
    RecipientRole = "client",             // → variables.recipient_role
    Variables = new Dictionary<string, object?> { ["offer_id"] = offer.Id },
    CorrelationId = $"offer-{offer.Id}-signed"   // ключ идемпотентности
}, ct);
{
  "NotificationGateway": {
    "DeliveryMode": "Http",
    "BaseUrl": "http://notification-gateway.notification-gateway-stage.svc.cluster.local",
    "SendPath": "/v1/notifications/send",
    "Scope": "notifications:notification.write"
  }
}
{
  "NotificationGateway": {
    "DeliveryMode": "RabbitMq",
    "Queue": "notification_gateway_send_event_queue",
    "RabbitMq": { "Host": "rmq-dl-stage-01.internal", "VirtualHost": "notification-gateway", "UserName": "customers" }
  },
  "OpenBao": { "KeyMap": { "notification-gateway-rabbitmq-password": "NotificationGateway:RabbitMq:Password" } }
}

Контракт сверен по исходникам: notification-gateway (main и sprint-08-04: NotificationMessage, SendNotificationV1Request, NotificationWorker/NotificationSendEventHandler, NotificationMessageMapper) и отправители - customers (NotificationGatewayRabbitPublisher, NotificationGatewayIntegrationService), broker-bff, life-core-api:

{"correlation_id":"6f0c...-guid","template_id":"broker-offer-signed","variables":{"client_id":"...","recipient_role":"client"}}
  • snake_case (gateway читает SnakeCaseLower, case-insensitive); вложенные объекты в variables тоже snake_case, ключи словаря - как есть.
  • correlation_id - всегда GUID: в очереди gateway десериализует его в Guid, и не-GUID ушёл бы в DLQ как parse_error; по HTTP не-GUID молча заменился бы новым. Поэтому: GUID - как есть, другая строка - детерминированный UUID v5 от неё (одинаковый на каждом повторе), пусто - новый. Все повторы одного вызова несут один correlation_id; статус - GET /delivery-logs?correlationId=....
  • Получатель (client_id, recipient_role, recipient_id) - ВНУТРИ variables, как у customers и broker-bff; поля верхнего уровня, которые шлёт life-core-api, gateway игнорирует.
  • Http: POST {BaseUrl}{SendPath} (за ingress с префиксом - /notification-gateway/v1/notifications/send, как в broker-bff) с токеном приложения через AddIdpClientCredentials(Scope) (личность - Idp:ClientId/ClientSecret). Повторы (MaxRetries 2, пауза от RetryDelayMs 200 мс, вдвое): сеть, таймаут (TimeoutSeconds 10), 408, 429, 5xx. 4xx (шаблона нет, валидация) - NotificationGatewayException сразу, с полем error ответа gateway. ClientCredentialsScopes (массив, как в customers) поддержан.
  • RabbitMq: default exchange, routing key = Queue (notification_gateway_send_event_queue, vhost notification-gateway), persistent, confirms, mandatory, message_id = correlation_id. Подключение - из подсекции NotificationGateway:RabbitMq (тогда очередь проверяется passive: заводит её владелец gateway) или RabbitMQ:Connections:notification-gateway.
  • Переменные уведомления (телефоны, ФИО, суммы) не пишутся в лог библиотекой.

Local development

Remote sources are skipped when DOTNET_ENVIRONMENT / ASPNETCORE_ENVIRONMENT is Development, Testing, or Local.

Force remote locally:

set MANDARIN_REMOTE_CONFIGURATION=true

Миграция сервиса на библиотеку

Порядок для сервисов, переезжающих на библиотеку (settings-gateway, life.billing, customers, life-core-api, notification-gateway, mts-id-integration, ai-connector, customer-documents, life-amo-crm-integration, uralsib-integration) и выкатки в k8s dl-stage.

Program.cs целиком (0.3.0)

Порядок вызовов важен: конфигурация (с секретами OpenBao) - до всего, что читает конфиг; ошибки - первым middleware, чтобы access-лог видел итоговый статус.

var builder = WebApplication.CreateBuilder(args);

builder.AddMandarinConfiguration(c => c.UseOpenBao());                 // 1. конфиг + секреты
builder.AddMandarinLogging(o => o.ServiceName = "customers");          // 2. логи
builder.Services.AddMandarinErrors();                                  // 3. ошибки
builder.Services.AddMandarinIdpAuth(a => a.EnableSelfIntrospection().EnableAppIntrospection()); // 4. IdP
builder.Services.AddMandarinNpgsql<CustomersDbContext>(builder.Configuration, o => o.MigrateOnStart = true); // 5. хранилища
builder.Services.AddMandarinRedis(builder.Configuration);
builder.Services.AddMandarinRabbitMq(builder.Configuration, "RabbitMQ", b => b.AddConsumer<ClientChangedHandler, ClientChanged>("customers.client_changed"));
builder.Services.AddNotificationGatewayClient(builder.Configuration);
builder.Services.AddMandarinHealth(o => o.LegacyAlias = LegacyHealthAlias.Live); // 6. health

var app = builder.Build();

app.UseMandarinErrors();
app.UseMandarinRequestLogging();
app.UseAuthentication();
app.UseAuthorization();
app.MapMandarinHealth();
app.MapControllers();
app.Run();
{
  "OpenBao": {
    "Uri": "https://openbao.dl-stage.internal",
    "SecretPaths": ["platform/customers"],
    "Auth": { "Role": "customers" },
    "KeyMap": {
      "idp-client-id": "Idp:ClientId",
      "idp-client-secret": "Idp:ClientSecret",
      "postgres-password": "Database:Password",
      "redis-password": "Redis:Password",
      "rabbitmq-password": "RabbitMQ:Connections:default:Password"
    }
  },
  "Idp": { "Authority": "http://identity-provider.identity-provider-stage.svc.cluster.local/", "ErrorFormat": "Legacy" },
  "Database": { "Host": "customers-db.internal", "Port": 5433, "Name": "customers", "User": "customers" },
  "Redis": { "Sentinels": ["10.66.4.31:26379", "10.66.4.32:26379", "10.66.4.33:26379"], "Database": 5 },
  "RabbitMQ": { "Connections": { "default": { "Host": "10.66.4.41", "VirtualHost": "customers", "UserName": "customers" } } },
  "NotificationGateway": { "BaseUrl": "http://notification-gateway.notification-gateway-stage.svc.cluster.local" }
}

Адреса, порты, номер базы Redis и vhost - из реестров площадки (infra-ansible), здесь для примера.

По шагам

  1. Пакеты. <PackageReference Include="Mandarin.Platform.Common" Version="0.3.0" /> и нужные из .Postgres / .Redis / .RabbitMq / .NotificationGateway той же версии; источник - GitLab-registry проекта 476 (nuget.config), либо nuget.org.

  2. Конфигурация. builder.AddMandarinConfiguration(c => c.UseOpenBao(...)) вместо своих провайдеров Vault/OpenBao. В appsettings.json — секция OpenBao (Uri, SecretPath или SecretPaths, Auth.Role = имя роли сервиса в OpenBao). Если в поде есть HTTPS_PROXY — ничего не делать: к OpenBao библиотека ходит напрямую. Внутренний CA — смонтировать PEM и указать OpenBao:CaCertPath.

  3. Секреты. Имена ключей в KV, не совпадающие с конфигом, — через OpenBao:KeyMap ("idp-client-secret": "Idp:ClientSecret", "postgres-password": "Database:Password", "redis-password": "Redis:Password"). Общие секреты платформы и секреты сервиса - разными путями в SecretPaths; доступ к путям выдаёт политика OpenBao (вне библиотеки).

  4. Логи. builder.AddMandarinLogging() вместо своего UseSerilog(...): секция Serilog из конфига читается как есть; свой RenderedCompactJsonFormatter-sink убрать или оставить в Serilog:WriteTo (тогда библиотека свой не добавит). Сервисные ключи с ПДн, которых нет во встроенном списке, - в LogMasking:Keys.

  5. Ошибки. Свой exception-middleware → AddMandarinErrors() + app.UseMandarinErrors() (первым). Доменные исключения - o.Map<T>(status, code).

  6. Входящая авторизация. AddMandarinIdpAuth(a => a.EnableSelfIntrospection() / EnableAppIntrospection()) вместо собственных BearerAuthorizeAttribute / introspection-клиентов. Старая секция OAuth2 подхватится автоматически (warning в логе) — перенести её в Idp можно отдельным шагом.

  7. Права. Легаси [Scope(X)] → [IdpScope(X)]. Подбор режима, чтобы поведение не изменилось:

    Как проверял сервис Idp:ScopeCheck
    право пользователя или скоуп токена (settings-gateway) ScopeOrPermission
    только user.perms PermissionOnly
    только scope токена ScopeOnly
    и то, и другое ScopeAndPermission (умолчание)

    Если сервис принимал …_all.<verb> вместо ….<verb> — Idp:AllSuffixImplies = true или явный список [IdpScope("svc:res.read", "svc:res_all.read")].

  8. Ответы об ошибках. Клиенты ждут {"error": "..."} - Idp:ErrorFormat = Legacy (его же подхватит AddMandarinErrors).

  9. Идентификатор вызывающего. Код, читавший client_id из сырого ответа, - на IdpUser.ClientId / IdpUser.Email. Приложения с нечисловым client_id теперь проходят; их UserId == 0.

  10. Исходящие вызовы. Собственные IdpTokenProvider / TokenPassThroughHandler удалить: AddIdpClientCredentials("scope") или AddIdpUserTokenPassThrough(...) на HttpClient, либо IIdpTokenProvider.GetTokenAsync(scope) из DI. Своего клиента notification-gateway - на AddNotificationGatewayClient (конфиг NotificationGateway:DeliveryMode/BaseUrl/SendPath совместим с customers и broker-bff).

  11. Хранилища. AddDbContext(o => o.UseNpgsql(cs)) → AddMandarinNpgsql<TContext> (строка из секции Database, MigrateOnStart вместо Database.Migrate() в Program.cs); свой ConnectionMultiplexer.Connect → AddMandarinRedis; свои ConnectionFactory/консьюмеры → AddMandarinRabbitMq (Passive - для очередей, которые заводит не сервис).

  12. Нагрузка на IdP. Для горячих ручек - Idp:IntrospectionCacheSeconds (например, 30); за IdP, который бывает недоступен, - Idp:UnavailableAs503 = true.

  13. Пробы. AddMandarinHealth() + MapMandarinHealth(); в манифесте livenessProbe → /health/live, readinessProbe → /health/ready. Старый /health - через LegacyAlias, пока манифесты не переведены.

  14. Проверка на dl-stage. Под стартует (OpenBao прочитан - fail-fast иначе; миграции накатились), /health/live и /health/ready 200, /health/deps показывает соседей, запрос с токеном пользователя и с токеном приложения проходят, запрос без права - 403, в логах JSON с CorrelationId и без ПДн.

Переход task-router / mango-telecom-connector / radist-connector

У них собственный IIdpTokenProvider с методом GetAsync(scope, ct), идентичный библиотечному по поведению. Переезд:

  • удалить локальные IIdpTokenProvider/IdpTokenProvider и их регистрацию (AddHttpClient(IdpTokenProvider.HttpClientName) + AddSingleton<IIdpTokenProvider, ...>);
  • using Mandarin.Platform.Common.Auth; и tokens.GetAsync(scope, ct) → tokens.GetTokenAsync(scope, ct);
  • регистрацию заменить на services.AddMandarinIdpClientCredentials() (или ничего, если уже есть AddMandarinIdpAuth); TryAddSingleton(TimeProvider.System) можно оставить — библиотека тоже регистрирует через TryAdd;
  • тесты со стабом IIdpTokenProvider — переименовать метод стаба.

⚠️ Поднимать пакет до 0.2.0 без этого шага нельзя в файлах, где одновременно подключены Mandarin.Platform.Common.Auth и пространство имён локального провайдера: имя IIdpTokenProvider станет неоднозначным (CS0104). Двоичная совместимость при этом сохранена — сборка, собранная против 0.1.x, с DLL 0.2.0 работает.

Environment overrides

Variable Description
VAULT_URI / OPENBAO_URI OpenBao base URL
VAULT_CONFIG_PATH Secret path (SecretPath; SecretPaths — только из конфига)
VAULT_NAMESPACE / OPENBAO_NAMESPACE OpenBao namespace (X-Vault-Namespace); empty = root
VAULT_TOKEN Static token (fallback)
VAULT_ROLE_ID / VAULT_SECRET_ID AppRole (fallback)
APOLLO_* Apollo open configuration

Build

dotnet restore
dotnet test
for p in src/*/*.csproj; do dotnet pack "$p" -c Release -o ./artifacts; done

Публикация

Тег X.Y.Z на main → CI собирает ВСЕ пакеты репозитория (src/*/*.csproj) с этой версией (зависимости между ними в .nuspec получают ту же версию) и публикует:

  • в GitLab-registry проекта (job publish, всегда);
  • на nuget.org (job publish:nuget-org), только если в Settings → CI/CD проекта заведена переменная NUGET_ORG_API_KEY (masked, protected). Без переменной job не создаётся.

Notes

  • Missing OpenBao settings with UseOpenBao() → fail-fast at startup in remote environments.
  • ClientId / ClientSecret for App introspection and outgoing calls come from OpenBao KeyMap (or User Secrets locally).
Product Compatible and additional computed target framework versions.
.NET net8.0 is compatible.  net8.0-android was computed.  net8.0-browser was computed.  net8.0-ios was computed.  net8.0-maccatalyst was computed.  net8.0-macos was computed.  net8.0-tvos was computed.  net8.0-windows was computed.  net9.0 was computed.  net9.0-android was computed.  net9.0-browser was computed.  net9.0-ios was computed.  net9.0-maccatalyst was computed.  net9.0-macos was computed.  net9.0-tvos was computed.  net9.0-windows was computed.  net10.0 was computed.  net10.0-android was computed.  net10.0-browser was computed.  net10.0-ios was computed.  net10.0-maccatalyst was computed.  net10.0-macos was computed.  net10.0-tvos was computed.  net10.0-windows was computed. 
Compatible target framework(s)
Included target framework(s) (in package)
Learn more about Target Frameworks and .NET Standard.

NuGet packages

This package is not used by any NuGet packages.

GitHub repositories

This package is not used by any popular GitHub repositories.

Version Downloads Last Updated
0.3.0 0 9/26/2026
0.2.1 0 9/26/2026
0.1.0 120 7/29/2026