Actors, Providers, Middleware — Практическое Руководство
Назначение
Документ объясняет, как работает actor/identity context в Pin: какие actors существуют, как middleware заполняет провайдеры, как handlers безопасно получают context без зависимости от HttpContext. Является практическим дополнением к Context Providers And Middlewares и Actors And Identity Building Blocks.
Модель Акторов
Типы Акторов
| Actor Type | Создаётся | Scope |
|---|---|---|
UserActor | Из JWT user token | Видит только свои user-owned ресурсы |
PartnerActor | Из JWT partner token | Видит только свои partner-owned ресурсы |
AdminActor | Из JWT admin token | Tenant-scoped; global требует permission + audit |
ProviderCallbackActor | Из verified provider signature | Нет user/admin permissions |
ServiceAccountActor | Из service-to-service auth | Internal scopes только |
SystemJobActor | В background/scheduler | Deterministic actor id + operation name |
IdentityContext
IdentityContext — нормализованный контекст текущего запроса или фоновой операции. Это единый объект, который handlers получают через IIdentityContextProvider.
// Talent.Common.Application
public sealed class IdentityContext
{
public ActorType ActorType { get; init; }
public string ActorId { get; init; } = default!;
// User-specific
public UserId? UserId { get; init; }
public TenantId? TenantId { get; init; }
public BrandId? BrandId { get; init; }
// Partner-specific
public PartnerId? PartnerId { get; init; }
// Admin-specific
public bool IsGlobalAdmin { get; init; }
public IReadOnlySet<string> Permissions { get; init; } = [];
// Snapshot для audit и events (безопасный, без credentials)
public ActorSnapshot ToSnapshot() => new()
{
ActorId = ActorId,
ActorType = ActorType.ToString(),
TenantId = TenantId?.ToString(),
BrandId = BrandId?.ToString(),
};
}
// Безопасный snapshot для audit, events, logs
public sealed class ActorSnapshot
{
public required string ActorId { get; init; }
public required string ActorType { get; init; }
public string? TenantId { get; init; }
public string? BrandId { get; init; }
public string? ImpersonationKind { get; init; }
public string? OnBehalfOfActorId { get; init; }
}
Providers
Интерфейсы Providers
// Talent.Common.Application
public interface IIdentityContextProvider
{
IdentityContext GetCurrent();
IdentityContext? TryGetCurrent();
}
public interface ITenantScopeProvider
{
TenantId GetRequired(); // бросает MissingScope если нет
TenantId? TryGet(); // nullable для background/system contexts
}
public interface IBrandScopeProvider
{
BrandId? TryGet(); // brand опциональна для admin/system
BrandId GetRequired();
}
public interface IUserScopeProvider
{
UserId GetRequired(); // только для user actor routes
UserId? TryGet();
}
public interface IPartnerScopeProvider
{
PartnerId GetRequired();
PartnerId? TryGet();
}
public interface ICorrelationContext
{
CorrelationId CorrelationId { get; }
string RequestId { get; }
string? TraceId { get; }
IdempotencyKey? IdempotencyKey { get; }
string? CausationId { get; }
}
public interface IClock
{
DateTimeOffset UtcNow { get; }
DateTimeOffset TodayUtc { get; }
}
Как Providers Реализованы
Providers реализованы в Infrastructure слое и используют IHttpContextAccessor (в ASP.NET контексте) или AsyncLocal (в background/test контексте):
// Talent.Common.Infrastructure (реализация)
// Не путать с Application (interfaces)
internal sealed class HttpContextTenantScopeProvider : ITenantScopeProvider
{
private readonly IHttpContextAccessor _httpContextAccessor;
public TenantId GetRequired()
{
var tenantId = TryGet();
return tenantId ?? throw new MissingScopeException("TenantId");
}
public TenantId? TryGet()
{
var ctx = _httpContextAccessor.HttpContext;
if (ctx is null) return null;
return ctx.Items.TryGetValue(PinContextKeys.TenantId, out var value)
? value as TenantId?
: null;
}
}
Для background/consumer контекста — AsyncLocalIdentityContextProvider:
internal sealed class AsyncLocalIdentityContextProvider : IIdentityContextProvider
{
private static readonly AsyncLocal<IdentityContext?> _context = new();
public IdentityContext GetCurrent() => _context.Value
?? throw new MissingScopeException("IdentityContext not set");
public IdentityContext? TryGetCurrent() => _context.Value;
public static IDisposable SetContext(IdentityContext context)
{
_context.Value = context;
return new ContextScope(() => _context.Value = null);
}
}
Middleware Order
Порядок middleware задан в API Middleware And Filters Standard и не меняется без ADR:
1. UseForwardedHeaders
2. UsePinCorrelation ← создаёт CorrelationId, RequestId
3. UsePinRequestContext ← service, environment, route, method, ip hash
4. UsePinExceptionHandling ← global exception → generic 500
5. UsePinSecurityHeaders ← safe headers, убирает server details
6. UsePinRequestLogging ← structured request logs с scope
7. UsePinHttpMetrics ← RED metrics с bounded labels
8. UsePinTenantBrandResolution ← TenantId + BrandId из host/route/headers
9. UseRouting
10. UseAuthentication ← JWT/service account/provider callback validation
11. UsePinActorContext ← создаёт IdentityContext из claims/token
12. UseAuthorization
13. UsePinIdempotency ← читает Idempotency-Key header
14. UsePinRateLimiting ← tenant/actor/provider-aware rate limit
15. MapControllers
Provider callback routes добавляют UsePinProviderCallbackSecurity перед шагом 11.
Как Правильно Использовать Context в Handlers
Правило: Handlers Не Читают Raw Claims
Запрещено:
// ПЛОХО: handler зависит от HTTP infrastructure
public async Task<Result<AccountingView>> Handle(GetAccountingQuery query, CancellationToken ct)
{
var userId = _httpContextAccessor.HttpContext!.User
.FindFirstValue("user_id"); // ← запрещено
return await _repository.FindByUserId(UserId.Parse(userId), ct);
}
Правильно:
// ХОРОШО: handler получает context через provider
public async Task<Result<AccountingView>> Handle(GetAccountingQuery query, CancellationToken ct)
{
var userId = _userScopeProvider.GetRequired(); // ← из middleware context
// Repository scope interceptor автоматически применит UserId filter
return await _repository.Find(query.AccountingId, ct);
}
Правило: Controllers Не Передают TenantId/BrandId/UserId Из Body
Запрещено:
// ПЛОХО: TenantId из body
[HttpGet("{accountingId}")]
public async Task<IActionResult> GetAccounting(
Guid accountingId,
[FromQuery] Guid tenantId) // ← запрещено для user/partner API
{
var result = await _mediator.Send(new GetAccountingQuery(accountingId, new TenantId(tenantId)));
// ...
}
Правильно:
// ХОРОШО: TenantId выводится из middleware
[HttpGet("{accountingId}")]
[Authorize(Policy = "UserOnly")]
public async Task<IActionResult> GetAccounting(
Guid accountingId, CancellationToken ct)
{
// Нет TenantId/UserId в параметрах — provider и interceptors делают это сами
var result = await _mediator.Send(new GetAccountingQuery(new AccountingId(accountingId)), ct);
return result.ToActionResult();
}
Правило: Domain Methods Получают ActorSnapshot, Не Actor Provider
Правильно:
public async Task<Result<CommandResult<WithdrawalRequestId>>> Handle(
ApproveWithdrawalCommand command, CancellationToken ct)
{
var actor = _identityContextProvider.GetCurrent().ToSnapshot(); // ← из provider
var withdrawal = await _repository.Get(command.WithdrawalId, ct);
var result = withdrawal.Approve(actor); // ← snapshot передаётся в domain method
if (result.IsFailed) return result;
await _repository.Update(withdrawal, ct);
return Result.Ok(CommandResult.Ok(withdrawal.Id));
}
Background И Consumer Context
Consumer создаёт IdentityContext из event envelope:
// Talent.Common.Messaging
public abstract class PinConsumerBase<TMessage> : IConsumer<TMessage>
where TMessage : class
{
private readonly IAsyncLocalIdentityContextSetter _contextSetter;
public async Task Consume(ConsumeContext<TMessage> context)
{
// 1. Restore correlation context from headers
var correlationId = context.Headers.Get<string>("Pin-correlation-id");
var tenantId = context.Headers.Get<string>("Pin-tenant-id");
// 2. Create SystemContext or restore from envelope
var identityContext = CreateConsumerContext(context);
// 3. Set AsyncLocal context for handlers
using (_contextSetter.SetContext(identityContext))
{
await ConsumeInternal(context);
}
}
protected abstract Task ConsumeInternal(ConsumeContext<TMessage> context);
protected abstract IdentityContext CreateConsumerContext(ConsumeContext<TMessage> context);
}
Tenant Resolution
Tenant/brand определяется в UsePinTenantBrandResolution middleware из:
- Host/domain mapping (первый приоритет для user-facing routes).
- Gateway-internal headers (
X-Tenant-Id,X-Brand-Id— только от trusted internal clients). - Route segment для admin/internal routes.
- Provider callback configuration.
- JWT claims (дополнительная проверка консистентности).
Если route и actor claims расходятся → 403 + security audit.
Публичные клиенты не могут указать TenantId/BrandId в body/headers — только через routing.
Привилегированные Сессии
Для high-impact admin routes (withdrawal approval, KYC decision, permission changes) требуется fresh MFA:
// Talent.Common.Identity
[HttpPost("{withdrawalId}/approve")]
[Authorize(Policy = "AdminWithPrivilegedSession")]
[RequiresFreshMfa(MaxAgeMinutes = 15)]
public async Task<IActionResult> ApproveWithdrawal(Guid withdrawalId, CancellationToken ct)
{ /* ... */ }
PrivilegedSessionMiddleware проверяет PrivilegedSessionToken в cookie/header, верифицирует свежесть MFA и записывает в context.
Правила Безопасности
UserId/PartnerId/UserIdне принимаются из public request body/query — только из actor context.X-Tenant-Id/X-Brand-Idheaders принимаются только от trusted internal clients (gateway, internal services).ProviderCallbackActorникогда не получает user/admin permissions.- Admin cross-tenant mode требует явного permission
admin.cross-tenant.access+ audit reason. - Mismatch между route tenant и JWT tenant →
403+ security audit log. - Raw claims в handlers запрещены; handlers используют typed providers.
Тестирование Actor Context
Для unit и integration тестов используется FakeActorContextModule из Pin.Kernel.Testing:
// В integration test setup
factory.WithWebHostBuilder(builder =>
{
builder.ConfigureTestServices(services =>
{
services.AddPinFakeActorContext(ctx =>
{
ctx.AsUser(
userId: UserId.NewId(),
tenantId: new TenantId(TestData.TenantId),
brandId: new BrandId(TestData.BrandId));
});
});
});
// Или как AdminActor с permissions
ctx.AsAdmin(
adminId: AdminUserId.NewId(),
tenantId: new TenantId(TestData.TenantId),
permissions: ["accounting.withdrawal.approve"]);
Каждый тест:
- Проверяет, что user actor не видит чужие данные.
- Проверяет, что unauthenticated request возвращает
401. - Проверяет, что actor без permission возвращает
403. - Проверяет, что provider callback с неверной подписью не создаёт actor context.
Ссылки
- Context Providers And Middlewares
- API Middleware And Filters
- Actors And Identity Building Blocks
- Execution Context Propagation
- 05 — Repository Pipeline
Обратные ссылки
Автоссылки: где используется этот документ.
- scenarios (1): UC-OVR-007. Просмотр каталога «глазами инвестора» (impersonation)
- index.md (1): Backend SDK — Руководство Разработчика
- 03-nuget-package-structure.md (1): NuGet Package Structure — Карта Building Blocks
- 05-repository-pipeline.md (1): Repository Pipeline и Scope Filters — Практическое Руководство
- backend-shared-standards-index.md (1): Backend Shared Standards Index