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
<PackageReference Include="Mandarin.Platform.Common" Version="0.3.0" />
<PackageVersion Include="Mandarin.Platform.Common" Version="0.3.0" />
<PackageReference Include="Mandarin.Platform.Common" />
paket add Mandarin.Platform.Common --version 0.3.0
#r "nuget: Mandarin.Platform.Common, 0.3.0"
#:package Mandarin.Platform.Common@0.3.0
#addin nuget:?package=Mandarin.Platform.Common&version=0.3.0
#tool nuget:?package=Mandarin.Platform.Common&version=0.3.0
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 ApolloUseOpenBao()/UseVault()— KV secrets; zero-trust default = Kubernetes SA JWTOpenBao: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/Localremote sources are off by default Mandarin.Platform.Common.Pagination— keysetcursor/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). - Умолчания:
Timeout15 с,CommandTimeout30 с,KeepAlive30 с,MaxPoolSize20 (не 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). Повторы (MaxRetries2, пауза отRetryDelayMs200 мс, вдвое): сеть, таймаут (TimeoutSeconds10), 408, 429, 5xx. 4xx (шаблона нет, валидация) -NotificationGatewayExceptionсразу, с полемerrorответа gateway.ClientCredentialsScopes(массив, как в customers) поддержан. - RabbitMq: default exchange, routing key =
Queue(notification_gateway_send_event_queue, vhostnotification-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), здесь для примера.
По шагам
Пакеты.
<PackageReference Include="Mandarin.Platform.Common" Version="0.3.0" />и нужные из.Postgres/.Redis/.RabbitMq/.NotificationGatewayтой же версии; источник - GitLab-registry проекта 476 (nuget.config), либо nuget.org.Конфигурация.
builder.AddMandarinConfiguration(c => c.UseOpenBao(...))вместо своих провайдеров Vault/OpenBao. Вappsettings.json— секцияOpenBao(Uri,SecretPathилиSecretPaths,Auth.Role= имя роли сервиса в OpenBao). Если в поде естьHTTPS_PROXY— ничего не делать: к OpenBao библиотека ходит напрямую. Внутренний CA — смонтировать PEM и указатьOpenBao:CaCertPath.Секреты. Имена ключей в KV, не совпадающие с конфигом, — через
OpenBao:KeyMap("idp-client-secret": "Idp:ClientSecret","postgres-password": "Database:Password","redis-password": "Redis:Password"). Общие секреты платформы и секреты сервиса - разными путями вSecretPaths; доступ к путям выдаёт политика OpenBao (вне библиотеки).Логи.
builder.AddMandarinLogging()вместо своегоUseSerilog(...): секцияSerilogиз конфига читается как есть; свойRenderedCompactJsonFormatter-sink убрать или оставить вSerilog:WriteTo(тогда библиотека свой не добавит). Сервисные ключи с ПДн, которых нет во встроенном списке, - вLogMasking:Keys.Ошибки. Свой exception-middleware →
AddMandarinErrors()+app.UseMandarinErrors()(первым). Доменные исключения -o.Map<T>(status, code).Входящая авторизация.
AddMandarinIdpAuth(a => a.EnableSelfIntrospection() / EnableAppIntrospection())вместо собственныхBearerAuthorizeAttribute/ introspection-клиентов. Старая секцияOAuth2подхватится автоматически (warning в логе) — перенести её вIdpможно отдельным шагом.Права. Легаси
[Scope(X)]→[IdpScope(X)]. Подбор режима, чтобы поведение не изменилось:Как проверял сервис Idp:ScopeCheckправо пользователя или скоуп токена (settings-gateway) ScopeOrPermissionтолько user.permsPermissionOnlyтолько scopeтокенаScopeOnlyи то, и другое ScopeAndPermission(умолчание)Если сервис принимал
…_all.<verb>вместо….<verb>—Idp:AllSuffixImplies = trueили явный список[IdpScope("svc:res.read", "svc:res_all.read")].Ответы об ошибках. Клиенты ждут
{"error": "..."}-Idp:ErrorFormat = Legacy(его же подхватитAddMandarinErrors).Идентификатор вызывающего. Код, читавший
client_idиз сырого ответа, - наIdpUser.ClientId/IdpUser.Email. Приложения с нечисловымclient_idтеперь проходят; ихUserId == 0.Исходящие вызовы. Собственные
IdpTokenProvider/TokenPassThroughHandlerудалить:AddIdpClientCredentials("scope")илиAddIdpUserTokenPassThrough(...)наHttpClient, либоIIdpTokenProvider.GetTokenAsync(scope)из DI. Своего клиента notification-gateway - наAddNotificationGatewayClient(конфигNotificationGateway:DeliveryMode/BaseUrl/SendPathсовместим с customers и broker-bff).Хранилища.
AddDbContext(o => o.UseNpgsql(cs))→AddMandarinNpgsql<TContext>(строка из секцииDatabase,MigrateOnStartвместоDatabase.Migrate()вProgram.cs); свойConnectionMultiplexer.Connect→AddMandarinRedis; своиConnectionFactory/консьюмеры →AddMandarinRabbitMq(Passive- для очередей, которые заводит не сервис).Нагрузка на IdP. Для горячих ручек -
Idp:IntrospectionCacheSeconds(например, 30); за IdP, который бывает недоступен, -Idp:UnavailableAs503 = true.Пробы.
AddMandarinHealth()+MapMandarinHealth(); в манифестеlivenessProbe→/health/live,readinessProbe→/health/ready. Старый/health- черезLegacyAlias, пока манифесты не переведены.Проверка на dl-stage. Под стартует (OpenBao прочитан - fail-fast иначе; миграции накатились),
/health/liveи/health/ready200,/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/ClientSecretfor App introspection and outgoing calls come from OpenBao KeyMap (or User Secrets locally).
| Product | Versions 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. |
-
net8.0
- Com.Ctrip.Framework.Apollo.Configuration (>= 2.11.0)
- Microsoft.Extensions.Caching.Memory (>= 8.0.1)
- Microsoft.Extensions.Configuration (>= 8.0.0)
- Microsoft.Extensions.Configuration.Abstractions (>= 8.0.0)
- Microsoft.Extensions.Configuration.Binder (>= 8.0.0)
- Microsoft.Extensions.Configuration.Json (>= 8.0.0)
- Microsoft.Extensions.DependencyInjection.Abstractions (>= 8.0.2)
- Microsoft.Extensions.Hosting.Abstractions (>= 8.0.0)
- Microsoft.Extensions.Http (>= 8.0.0)
- Microsoft.Extensions.Options.ConfigurationExtensions (>= 8.0.0)
- Serilog.AspNetCore (>= 8.0.3)
NuGet packages
This package is not used by any NuGet packages.
GitHub repositories
This package is not used by any popular GitHub repositories.