Перейти к основному содержимому

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 tokenTenant-scoped; global требует permission + audit
ProviderCallbackActorИз verified provider signatureНет user/admin permissions
ServiceAccountActorИз service-to-service authInternal scopes только
SystemJobActorВ background/schedulerDeterministic 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 из:

  1. Host/domain mapping (первый приоритет для user-facing routes).
  2. Gateway-internal headers (X-Tenant-Id, X-Brand-Id — только от trusted internal clients).
  3. Route segment для admin/internal routes.
  4. Provider callback configuration.
  5. 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-Id headers принимаются только от 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

Обратные ссылки

Автоссылки: где используется этот документ.