Stage deploy crashed in CrashLoopBackoff because the production container landed on an empty fresh Postgres, then OpenIddictClientSeeder hit "relation public.OpenIddictApplications does not exist". The Migrate() call was guarded by IsDevelopment() so prod never bootstrapped. Migrations are idempotent — running them every startup is the standard pattern for SaaS containers (no separate migrate-then-app step needed). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
208 lines
8.3 KiB
C#
208 lines
8.3 KiB
C#
using System.Security.Claims;
|
|
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>();
|
|
|
|
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"));
|
|
})
|
|
.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"))));
|
|
});
|
|
|
|
builder.Services.AddControllers();
|
|
builder.Services.AddEndpointsApiExplorer();
|
|
builder.Services.AddSwaggerGen();
|
|
|
|
// MoySklad import integration. Auto-decompress gzip responses from MoySklad's edge.
|
|
builder.Services.AddHttpClient<foodmarket.Infrastructure.Integrations.MoySklad.MoySkladClient>()
|
|
.ConfigurePrimaryHttpMessageHandler(() => new HttpClientHandler
|
|
{
|
|
AutomaticDecompression = System.Net.DecompressionMethods.GZip | System.Net.DecompressionMethods.Deflate,
|
|
});
|
|
builder.Services.AddScoped<foodmarket.Infrastructure.Integrations.MoySklad.MoySkladImportService>();
|
|
|
|
// Inventory
|
|
builder.Services.AddScoped<foodmarket.Application.Inventory.IStockService, foodmarket.Infrastructure.Inventory.StockService>();
|
|
|
|
builder.Services.AddHostedService<OpenIddictClientSeeder>();
|
|
builder.Services.AddHostedService<SystemReferenceSeeder>();
|
|
builder.Services.AddHostedService<DevDataSeeder>();
|
|
// 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);
|
|
app.UseAuthentication();
|
|
app.UseAuthorization();
|
|
|
|
if (app.Environment.IsDevelopment())
|
|
{
|
|
app.UseSwagger();
|
|
app.UseSwaggerUI();
|
|
}
|
|
|
|
app.MapControllers();
|
|
|
|
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", (HttpContext ctx) =>
|
|
{
|
|
var user = ctx.User;
|
|
return Results.Ok(new
|
|
{
|
|
sub = user.FindFirst(Claims.Subject)?.Value,
|
|
name = user.Identity?.Name,
|
|
email = user.FindFirst(Claims.Email)?.Value,
|
|
roles = user.FindAll(Claims.Role).Select(c => c.Value),
|
|
orgId = user.FindFirst(HttpContextTenantContext.OrganizationClaim)?.Value,
|
|
});
|
|
}).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();
|
|
}
|