food-market/src/food-market.api/Program.cs
nns 28010fafdb feat(authz): permission-based авторизация по флагам роли (P0-5)
PermissionAuthorizationHandler + [RequiresPermission("...")] + динамический
PermissionAuthorizationPolicyProvider (policy perm:*). Доступ определяют флаги
RolePermissions роли сотрудника (live из БД), а не зашитый список Identity-ролей.

SuperAdmin и Identity-роль Admin (= системная «Администратор» с All()) —
полный доступ шорткатом; custom-роли не маппятся на Admin, поэтому шорткат их
не задевает. Нет активного Employee/нет флага → 403 (fail-closed).

Заменены [Authorize(Roles=...)] в каталоге (Products/ProductGroups/PriceTypes/
Counterparties/Stores/RetailPoints/Units/ProductImages) и документах (Supplies/
RetailSales) на конкретные права. Currencies/Countries оставлены SuperAdmin
(глобальный справочник, не org-permission).

Проверено curl на :5091: custom-роль без ProductsEdit → PUT товара 403;
GET 200; admin/после выдачи права → 400 (не 403). Закрывает «роли — фикция» из аудита.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 02:37:28 +05:00

340 lines
17 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

using System.Security.Claims;
using foodmarket.Api.Infrastructure.RateLimiting;
using foodmarket.Api.Infrastructure.Tenancy;
using foodmarket.Api.Seed;
using foodmarket.Application.Common.Tenancy;
using foodmarket.Infrastructure.Identity;
using foodmarket.Infrastructure.Persistence;
using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore;
using OpenIddict.Validation.AspNetCore;
using Serilog;
using static OpenIddict.Abstractions.OpenIddictConstants;
Log.Logger = new LoggerConfiguration()
.WriteTo.Console()
.CreateBootstrapLogger();
try
{
var builder = WebApplication.CreateBuilder(args);
builder.Host.UseSerilog((ctx, services, cfg) =>
cfg.ReadFrom.Configuration(ctx.Configuration).ReadFrom.Services(services));
const string CorsPolicy = "food-market-cors";
builder.Services.AddCors(opts => opts.AddPolicy(CorsPolicy, p =>
{
var origins = builder.Configuration.GetSection("Cors:AllowedOrigins").Get<string[]>()
?? ["http://localhost:5173"];
p.WithOrigins(origins).AllowAnyHeader().AllowAnyMethod().AllowCredentials();
}));
builder.Services.AddHttpContextAccessor();
builder.Services.AddScoped<ITenantContext, HttpContextTenantContext>();
// ClaimsTransformation для SuperAdmin override: добавляет роли Admin/
// Storekeeper/Cashier при наличии X-Org-Override, чтобы [Authorize(Roles=...)]
// не отшивал edit-mode мутации SuperAdmin'а. См. SuperAdminOverrideClaimsTransformer.
builder.Services.AddScoped<Microsoft.AspNetCore.Authentication.IClaimsTransformation,
foodmarket.Api.Infrastructure.Tenancy.SuperAdminOverrideClaimsTransformer>();
builder.Services.AddDbContext<AppDbContext>(opts =>
{
opts.UseNpgsql(builder.Configuration.GetConnectionString("Default"),
npg => npg.MigrationsAssembly(typeof(AppDbContext).Assembly.GetName().Name));
opts.UseOpenIddict();
});
builder.Services.AddIdentity<User, Role>(opts =>
{
opts.Password.RequireDigit = true;
opts.Password.RequiredLength = 8;
opts.Password.RequireNonAlphanumeric = false;
opts.Password.RequireUppercase = false;
opts.User.RequireUniqueEmail = true;
})
.AddEntityFrameworkStores<AppDbContext>()
.AddDefaultTokenProviders();
builder.Services.Configure<IdentityOptions>(opts =>
{
opts.ClaimsIdentity.UserNameClaimType = Claims.Name;
opts.ClaimsIdentity.UserIdClaimType = Claims.Subject;
opts.ClaimsIdentity.RoleClaimType = Claims.Role;
});
builder.Services.AddOpenIddict()
.AddCore(opts => opts.UseEntityFrameworkCore().UseDbContext<AppDbContext>())
.AddServer(opts =>
{
opts.SetTokenEndpointUris("/connect/token");
opts.AllowPasswordFlow().AllowRefreshTokenFlow();
opts.AcceptAnonymousClients();
opts.RegisterScopes(Scopes.OpenId, Scopes.Profile, Scopes.Email, Scopes.Roles, "api");
// Persistent dev keys: RSA key stored in src/food-market.api/App_Data/openiddict-dev-key.xml.
// Survives API restarts so issued tokens remain valid across rebuilds.
// Never commit this file (it's in .gitignore via App_Data/). Production must use real certificates.
var keyPath = Path.Combine(builder.Environment.ContentRootPath, "App_Data", "openiddict-dev-key.xml");
var rsa = System.Security.Cryptography.RSA.Create(2048);
if (File.Exists(keyPath))
{
rsa.FromXmlString(File.ReadAllText(keyPath));
}
else
{
Directory.CreateDirectory(Path.GetDirectoryName(keyPath)!);
File.WriteAllText(keyPath, rsa.ToXmlString(includePrivateParameters: true));
}
var devKey = new Microsoft.IdentityModel.Tokens.RsaSecurityKey(rsa) { KeyId = "food-market-dev" };
opts.AddEncryptionKey(devKey);
opts.AddSigningKey(devKey);
if (builder.Environment.IsDevelopment())
{
opts.DisableAccessTokenEncryption();
}
opts.UseAspNetCore()
.EnableTokenEndpointPassthrough()
.DisableTransportSecurityRequirement();
opts.SetAccessTokenLifetime(TimeSpan.Parse(
builder.Configuration["OpenIddict:AccessTokenLifetime"] ?? "01:00:00"));
opts.SetRefreshTokenLifetime(TimeSpan.Parse(
builder.Configuration["OpenIddict:RefreshTokenLifetime"] ?? "30.00:00:00"));
// Rolling refresh включён по умолчанию: каждый refresh гасит старый
// токен (Redeemed) и выдаёт новый. Но по умолчанию OpenIddict даёт
// 30-секундный reuse-leeway — окно, в котором погашенный refresh ещё
// принимается (для гонок/ретраев). Для розничной админки это дыра:
// утёкший refresh остаётся рабочим 30с после ротации. Обнуляем окно —
// ротация инвалидирует старый refresh немедленно.
opts.SetRefreshTokenReuseLeeway(TimeSpan.Zero);
// Явный Issuer = публичный URL админки. Без него OpenIddict
// вычисляет issuer из текущего HTTP-запроса, что ломается за
// nginx-прокси если Host/X-Forwarded-Proto не доходят. Берём
// из конфигурации (OpenIddict:Issuer / docker compose env).
if (builder.Configuration["OpenIddict:Issuer"] is { Length: > 0 } issuer)
{
opts.SetIssuer(new Uri(issuer));
}
})
.AddValidation(opts =>
{
opts.UseLocalServer();
opts.UseAspNetCore();
});
// Force OpenIddict validation to handle ALL [Authorize] challenges — otherwise AddIdentity's
// cookie scheme takes over and 401 becomes a 302 redirect to /Account/Login for API calls.
builder.Services.AddAuthentication(options =>
{
options.DefaultScheme = OpenIddictValidationAspNetCoreDefaults.AuthenticationScheme;
options.DefaultAuthenticateScheme = OpenIddictValidationAspNetCoreDefaults.AuthenticationScheme;
options.DefaultChallengeScheme = OpenIddictValidationAspNetCoreDefaults.AuthenticationScheme;
});
builder.Services.AddAuthorization(opts =>
{
// Check the "role" claim explicitly — robust against role-claim-type mismatches between
// OpenIddict validation identity and the default ClaimTypes.Role uri.
opts.AddPolicy("AdminAccess", p => p.RequireAssertion(ctx =>
ctx.User.HasClaim(c => c.Type == Claims.Role && (c.Value == "Admin" || c.Value == "SuperAdmin"))));
});
// Permission-based авторизация: [RequiresPermission("...")] → policy perm:* →
// PermissionRequirement → проверка флага RolePermissions роли сотрудника.
builder.Services.AddSingleton<Microsoft.AspNetCore.Authorization.IAuthorizationPolicyProvider,
foodmarket.Api.Infrastructure.Authorization.PermissionAuthorizationPolicyProvider>();
builder.Services.AddScoped<Microsoft.AspNetCore.Authorization.IAuthorizationHandler,
foodmarket.Api.Infrastructure.Authorization.PermissionAuthorizationHandler>();
builder.Services.AddScoped<foodmarket.Api.Infrastructure.Tenancy.SuperAdminEditAuditFilter>();
// Anti-brute-force на /connect/token и /api/auth/signup (5/мин + 20/час на IP).
builder.Services.AddAuthRateLimiting();
// Health-пробы: liveness (процесс жив) и readiness (БД + миграции применены).
// Readiness-чек помечен тегом "ready", чтобы /health/live его не запускал.
builder.Services.AddHealthChecks()
.AddCheck<foodmarket.Api.Infrastructure.Health.DatabaseReadyHealthCheck>(
"database", tags: ["ready"]);
// Email-отправка через MailKit. Singleton — внутри открывает scope для
// свежего DbContext'а, поэтому конфиг (PlatformSettings) перечитывается
// на каждой отправке без рестарта приложения.
builder.Services.AddSingleton<foodmarket.Application.Common.Email.IEmailSender,
foodmarket.Infrastructure.Email.MailKitEmailSender>();
builder.Services.AddDataProtection();
builder.Services.AddControllers(o =>
{
// Глобальный action filter — пишет audit-log при успешных мутациях
// в режиме «SuperAdmin открыть как… + edit-mode» (Phase 3).
o.Filters.AddService<foodmarket.Api.Infrastructure.Tenancy.SuperAdminEditAuditFilter>();
});
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();
// MoySklad import integration. Auto-decompress gzip responses from MoySklad's edge.
// BaseAddress берётся из конфигурации (MoySklad:BaseUrl) с дефолтом на боевой
// api.moysklad.ru — так интеграцию можно навести на mock-сервер в e2e/интеграционных
// тестах, не трогая прод. Трейлинг-слэш обязателен (RFC 3986 §5.3, см. MoySkladClient).
builder.Services.AddHttpClient<foodmarket.Infrastructure.Integrations.MoySklad.MoySkladClient>(http =>
{
var baseUrl = builder.Configuration["MoySklad:BaseUrl"];
if (!string.IsNullOrWhiteSpace(baseUrl))
http.BaseAddress = new Uri(baseUrl);
})
.ConfigurePrimaryHttpMessageHandler(() => new HttpClientHandler
{
AutomaticDecompression = System.Net.DecompressionMethods.GZip | System.Net.DecompressionMethods.Deflate,
});
builder.Services.AddScoped<foodmarket.Infrastructure.Integrations.MoySklad.MoySkladImportService>();
builder.Services.AddSingleton<foodmarket.Infrastructure.Integrations.MoySklad.ImportJobRegistry>();
// Inventory
builder.Services.AddScoped<foodmarket.Application.Inventory.IStockService, foodmarket.Infrastructure.Inventory.StockService>();
builder.Services.AddHostedService<OpenIddictClientSeeder>();
builder.Services.AddHostedService<SystemReferenceSeeder>();
builder.Services.AddHostedService<DevDataSeeder>();
builder.Services.AddHostedService<foodmarket.Api.Background.ReferencePriceRefreshJob>();
// DemoCatalogSeeder disabled: real catalog is imported from MoySklad.
// Keep the file as reference for anyone starting without MoySklad access —
// just re-register here to turn demo data back on.
// builder.Services.AddHostedService<DemoCatalogSeeder>();
var app = builder.Build();
app.UseSerilogRequestLogging();
app.UseCors(CorsPolicy);
// До аутентификации: лимитируем перебор пароля ещё на входе, не доводя
// до проверки credential'ов в БД.
app.UseRateLimiter();
app.UseAuthentication();
app.UseAuthorization();
// SuperAdmin «открыть как…» — тот же tenant как у выбранной орги, но
// только GET. Любая мутация → 403, кроме /api/super-admin/* и /connect/*.
app.UseMiddleware<foodmarket.Api.Infrastructure.Tenancy.ReadonlyOverrideMiddleware>();
// Статика товарных изображений: физически /app/uploads (volume в compose),
// публичный URL /uploads/... — раздаются public, без auth.
var uploadsDir = System.IO.Path.Combine(app.Environment.ContentRootPath, "uploads");
System.IO.Directory.CreateDirectory(uploadsDir);
app.UseStaticFiles(new Microsoft.AspNetCore.Builder.StaticFileOptions
{
FileProvider = new Microsoft.Extensions.FileProviders.PhysicalFileProvider(uploadsDir),
RequestPath = "/uploads",
});
if (app.Environment.IsDevelopment())
{
app.UseSwagger();
app.UseSwaggerUI();
}
app.MapControllers();
// Liveness: процесс отвечает — без обращения к зависимостям (Predicate=false
// => ни один чек не запускается). Используется для рестарта зависшего контейнера.
app.MapHealthChecks("/health/live", new Microsoft.AspNetCore.Diagnostics.HealthChecks.HealthCheckOptions
{
Predicate = _ => false,
ResponseWriter = WriteHealthResponse,
});
// Readiness: запускает чеки с тегом "ready" (БД + миграции). 503 если не готово.
app.MapHealthChecks("/health/ready", new Microsoft.AspNetCore.Diagnostics.HealthChecks.HealthCheckOptions
{
Predicate = check => check.Tags.Contains("ready"),
ResponseWriter = WriteHealthResponse,
});
// Backward-compat: /health = liveness (Dockerfile/nginx исторически бьют сюда).
app.MapGet("/health", () => Results.Ok(new { status = "ok", time = DateTime.UtcNow }));
app.MapGet("/api/_debug/whoami", (HttpContext ctx) =>
{
var identity = ctx.User.Identity as System.Security.Claims.ClaimsIdentity;
return Results.Ok(new
{
isAuthenticated = ctx.User.Identity?.IsAuthenticated,
authType = ctx.User.Identity?.AuthenticationType,
nameClaimType = identity?.NameClaimType,
roleClaimType = identity?.RoleClaimType,
isInRoleAdmin = ctx.User.IsInRole("Admin"),
hasAdminRoleClaim = ctx.User.HasClaim(c => c.Type == Claims.Role && c.Value == "Admin"),
claims = ctx.User.Claims.Select(c => new { c.Type, c.Value }),
});
}).RequireAuthorization();
app.MapGet("/api/me", async (HttpContext ctx, AppDbContext db) =>
{
var user = ctx.User;
var roles = user.FindAll(Claims.Role).Select(c => c.Value).ToList();
var orgIdRaw = user.FindFirst(HttpContextTenantContext.OrganizationClaim)?.Value;
var subRaw = user.FindFirst(Claims.Subject)?.Value;
// Дополнительные сигналы, чтобы фронт точно знал в каком состоянии юзер
// находится: orphan AppUser без живой org или без активного Employee
// получает /no-organization fallback вместо белого экрана на /dashboard.
bool hasLiveOrg = false;
bool hasActiveEmployee = false;
if (Guid.TryParse(orgIdRaw, out var orgId))
{
hasLiveOrg = await db.Organizations.IgnoreQueryFilters()
.AnyAsync(o => o.Id == orgId && !o.IsArchived);
if (Guid.TryParse(subRaw, out var sub))
{
hasActiveEmployee = await db.Employees.IgnoreQueryFilters()
.AnyAsync(e => e.OrganizationId == orgId && e.UserId == sub && e.IsActive);
}
}
return Results.Ok(new
{
sub = subRaw,
name = user.Identity?.Name,
email = user.FindFirst(Claims.Email)?.Value,
roles,
orgId = orgIdRaw,
hasLiveOrg,
hasActiveEmployee,
});
}).RequireAuthorization();
// Apply migrations on every startup (idempotent). Without this, fresh
// stage/prod deploys land on an empty DB and OpenIddict seeders fail.
using (var scope = app.Services.CreateScope())
{
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
db.Database.Migrate();
}
app.Run();
}
catch (Exception ex)
{
Log.Fatal(ex, "Application terminated unexpectedly");
}
finally
{
Log.CloseAndFlush();
}
// JSON-ответ health-проб: общий статус + по каждому чеку. text/plain по умолчанию
// неудобен для мониторинга; отдаём структурировано.
static Task WriteHealthResponse(HttpContext context,
Microsoft.Extensions.Diagnostics.HealthChecks.HealthReport report)
{
context.Response.ContentType = "application/json";
return context.Response.WriteAsJsonAsync(new
{
status = report.Status.ToString(),
totalDurationMs = report.TotalDuration.TotalMilliseconds,
checks = report.Entries.Select(e => new
{
name = e.Key,
status = e.Value.Status.ToString(),
description = e.Value.Description,
}),
});
}