Files
PowderCoatingLogix/src/PowderCoating.Web/Program.cs
T
spouliot 1a44133a63 Remove ShopWorker entity and migrate worker identity to ApplicationUser
Removes the ShopWorker and ShopWorkerRoleCost entities, all related DTOs,
mappings, controllers, views, and import/export paths. Worker identity is
now handled entirely through ApplicationUser with per-user LaborCostPerHour.
ShopWorkerRoleCosts table remains in production pending manual data migration.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-15 20:32:32 -04:00

1032 lines
46 KiB
C#

using System.Threading.RateLimiting;
using Microsoft.AspNetCore.DataProtection;
using Microsoft.AspNetCore.DataProtection.EntityFrameworkCore;
using Microsoft.AspNetCore.RateLimiting;
using Microsoft.AspNetCore.HttpOverrides;
using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore;
using PowderCoating.Core.Entities;
using PowderCoating.Core.Interfaces;
using PowderCoating.Core.Interfaces.Services;
using PowderCoating.Infrastructure.Data;
using PowderCoating.Infrastructure.Repositories;
using PowderCoating.Infrastructure.Services;
using PowderCoating.Application.Interfaces;
using PowderCoating.Application.Services;
using PowderCoating.Application.Configuration;
using PowderCoating.Shared.Constants;
using PowderCoating.Web.BackgroundServices;
using Serilog;
using Serilog.Sinks.MSSqlServer;
using Serilog.Sinks.ApplicationInsights.TelemetryConverters;
using AutoMapper;
using PowderCoating.Application.Mappings;
var builder = WebApplication.CreateBuilder(args);
// Bootstrap logger — console only, used during startup before connection string is available.
// Replaced below with the full logger (files + DB sink) once the connection string is known.
// On Azure App Service the content root is read-only; use the writable HOME/LogFiles path instead.
var isAzure = !string.IsNullOrEmpty(Environment.GetEnvironmentVariable("WEBSITE_SITE_NAME"));
var logsPath = isAzure
? Path.Combine(Environment.GetEnvironmentVariable("HOME")!, "LogFiles", "Application")
: Path.Combine(builder.Environment.ContentRootPath, "logs");
Directory.CreateDirectory(logsPath);
// Route Serilog internal errors (e.g. SQL sink failures) to stderr — never crash the app.
Serilog.Debugging.SelfLog.Enable(Console.Error);
Log.Logger = new LoggerConfiguration()
.WriteTo.Console(outputTemplate: "[{Timestamp:HH:mm:ss} {Level:u3}] {Message:lj}{NewLine}{Exception}")
.CreateLogger();
builder.Host.UseSerilog();
// Log startup information
Log.Information("Starting PowderCoating.Web application");
Log.Information("Environment: {Environment}", builder.Environment.EnvironmentName);
Log.Information("Logs directory: {LogsPath}", logsPath);
// Add services to the container
var connectionString = builder.Configuration.GetConnectionString("DefaultConnection")
?? throw new InvalidOperationException("Connection string 'DefaultConnection' not found.");
// Re-configure Serilog now that the connection string is available — add DB sink for Warning+
Log.Logger = new LoggerConfiguration()
.ReadFrom.Configuration(builder.Configuration)
.Enrich.FromLogContext()
.Enrich.WithProperty("Environment", builder.Environment.EnvironmentName)
.Enrich.WithProperty("Application", "PowderCoating.Web")
.WriteTo.Console(
outputTemplate: "[{Timestamp:HH:mm:ss} {Level:u3}] {Message:lj} {Properties:j}{NewLine}{Exception}")
.WriteTo.File(
path: Path.Combine(logsPath, "powdercoating-.txt"),
rollingInterval: RollingInterval.Day,
retainedFileCountLimit: 30,
outputTemplate: "{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} [{Level:u3}] [{SourceContext}] {Message:lj} {Properties:j}{NewLine}{Exception}",
shared: true,
flushToDiskInterval: TimeSpan.FromSeconds(1))
.WriteTo.File(
path: Path.Combine(logsPath, "errors-.txt"),
rollingInterval: RollingInterval.Day,
retainedFileCountLimit: 90,
restrictedToMinimumLevel: Serilog.Events.LogEventLevel.Error,
outputTemplate: "{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} [{Level:u3}] [{SourceContext}] {Message:lj} {Properties:j}{NewLine}{Exception}",
shared: true,
flushToDiskInterval: TimeSpan.FromSeconds(1))
// SQL sink disabled — Azure SQL connection resets on Linux cause sink instability.
//.WriteTo.MSSqlServer(...)
.CreateLogger();
// Application Insights sink — production only, requires ApplicationInsights:ConnectionString in config.
// Writes Warning+ structured logs to Azure Monitor. The in-app SystemLogsController queries the same
// workspace via LogsQueryClient so SuperAdmins can view logs without opening the Azure portal.
var aiConnectionString = builder.Configuration["ApplicationInsights:ConnectionString"];
if (builder.Environment.IsProduction() && !string.IsNullOrWhiteSpace(aiConnectionString))
{
var telemetryConfig = new Microsoft.ApplicationInsights.Extensibility.TelemetryConfiguration
{
ConnectionString = aiConnectionString
};
Log.Logger = new LoggerConfiguration()
.ReadFrom.Configuration(builder.Configuration)
.Enrich.FromLogContext()
.Enrich.WithProperty("Environment", builder.Environment.EnvironmentName)
.Enrich.WithProperty("Application", "PowderCoating.Web")
.WriteTo.Console(
outputTemplate: "[{Timestamp:HH:mm:ss} {Level:u3}] {Message:lj} {Properties:j}{NewLine}{Exception}")
.WriteTo.File(
path: Path.Combine(logsPath, "powdercoating-.txt"),
rollingInterval: RollingInterval.Day,
retainedFileCountLimit: 30,
outputTemplate: "{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} [{Level:u3}] [{SourceContext}] {Message:lj} {Properties:j}{NewLine}{Exception}",
shared: true,
flushToDiskInterval: TimeSpan.FromSeconds(1))
.WriteTo.File(
path: Path.Combine(logsPath, "errors-.txt"),
rollingInterval: RollingInterval.Day,
retainedFileCountLimit: 90,
restrictedToMinimumLevel: Serilog.Events.LogEventLevel.Error,
outputTemplate: "{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} [{Level:u3}] [{SourceContext}] {Message:lj} {Properties:j}{NewLine}{Exception}",
shared: true,
flushToDiskInterval: TimeSpan.FromSeconds(1))
.WriteTo.ApplicationInsights(
telemetryConfig,
TelemetryConverter.Traces,
restrictedToMinimumLevel: Serilog.Events.LogEventLevel.Warning)
.CreateLogger();
Log.Information("Application Insights sink enabled.");
}
builder.Services.AddSingleton<PowderCoating.Infrastructure.Data.AuditInterceptor>();
builder.Services.AddDbContext<ApplicationDbContext>((sp, options) =>
{
options.UseSqlServer(connectionString, sqlOptions =>
{
sqlOptions.EnableRetryOnFailure(
maxRetryCount: 5,
maxRetryDelay: TimeSpan.FromSeconds(10),
errorNumbersToAdd: null);
});
options.AddInterceptors(sp.GetRequiredService<PowderCoating.Infrastructure.Data.AuditInterceptor>());
});
builder.Services.AddDatabaseDeveloperPageExceptionFilter();
// Configure Data Protection
// Production: keys stored in Azure Blob Storage (survives restarts and scales across instances)
// Development: keys stored on local filesystem
if (builder.Environment.IsProduction())
{
var storageConnectionString = builder.Configuration["Storage:ConnectionString"]
?? throw new InvalidOperationException("Storage:ConnectionString is required in production.");
builder.Services.AddDataProtection()
.PersistKeysToAzureBlobStorage(storageConnectionString, "dataprotection", "keys.xml")
.SetApplicationName("PowderCoatingApp");
}
else
{
// Non-production: store keys in SQL Server so they survive deploys and IIS app pool recycles
// without needing filesystem write permissions on the web root.
builder.Services.AddDataProtection()
.PersistKeysToDbContext<ApplicationDbContext>()
.SetApplicationName("PowderCoatingApp");
}
// Configure Identity
builder.Services.AddIdentity<ApplicationUser, IdentityRole>(options =>
{
options.SignIn.RequireConfirmedAccount = false;
// Strong password policy
options.Password.RequireDigit = true;
options.Password.RequireLowercase = true;
options.Password.RequireUppercase = true;
options.Password.RequireNonAlphanumeric = true; // SECURITY: Require special characters
options.Password.RequiredLength = 12; // SECURITY: Increased from 8 to 12
options.Password.RequiredUniqueChars = 4; // SECURITY: Require variety
options.User.RequireUniqueEmail = true;
// Account lockout for brute force protection
options.Lockout.DefaultLockoutTimeSpan = TimeSpan.FromMinutes(15);
options.Lockout.MaxFailedAccessAttempts = 5;
options.Lockout.AllowedForNewUsers = true;
})
.AddEntityFrameworkStores<ApplicationDbContext>()
.AddDefaultTokenProviders()
.AddDefaultUI()
.AddClaimsPrincipalFactory<ApplicationUserClaimsPrincipalFactory>();
// Register HttpContextAccessor for multi-tenancy
builder.Services.AddHttpContextAccessor();
// Register multi-tenancy services
builder.Services.AddScoped<ITenantContext, TenantContext>();
// Register repositories and services
builder.Services.AddScoped<IUnitOfWork, UnitOfWork>();
builder.Services.AddScoped(typeof(IRepository<>), typeof(Repository<>));
builder.Services.AddScoped<IFileService, FileService>();
// Azure Blob Storage configuration
builder.Services.Configure<StorageSettings>(builder.Configuration.GetSection("Storage"));
builder.Services.AddSingleton<IAzureBlobStorageService, AzureBlobStorageService>();
builder.Services.AddScoped<IProfilePhotoService, ProfilePhotoService>();
builder.Services.AddScoped<IJobPhotoService, JobPhotoService>();
builder.Services.AddScoped<IQuotePhotoService, QuotePhotoService>();
builder.Services.AddScoped<ICatalogImageService, CatalogImageService>();
builder.Services.AddScoped<IAiQuoteService, AiQuoteService>();
builder.Services.AddScoped<IAiQuickQuoteService, AiQuickQuoteService>();
builder.Services.AddSingleton<IAiUsageLogger, AiUsageLogger>();
builder.Services.AddScoped<IAiSchedulingService, AiSchedulingService>();
builder.Services.AddScoped<IAccountingAiService, AccountingAiService>();
builder.Services.AddScoped<IFinancialReportService, FinancialReportService>();
builder.Services.AddScoped<IOperationalReportService, OperationalReportService>();
builder.Services.AddScoped<IAiHelpService, AiHelpService>();
builder.Services.AddScoped<IAiCatalogPriceCheckService, AiCatalogPriceCheckService>();
builder.Services.AddScoped<IInventoryAiLookupService, InventoryAiLookupService>();
builder.Services.AddHttpClient();
builder.Services.AddScoped<ICompanyLogoService, CompanyLogoService>();
builder.Services.AddScoped<IEquipmentManualService, EquipmentManualService>();
builder.Services.AddScoped<IPricingCalculationService, PricingCalculationService>();
builder.Services.AddScoped<IJobItemAssemblyService, JobItemAssemblyService>();
builder.Services.AddScoped<IQuotePricingAssemblyService, QuotePricingAssemblyService>();
builder.Services.AddScoped<IPowderInsightsService, PowderInsightsService>();
builder.Services.AddScoped<IAuditLogService, AuditLogService>();
builder.Services.AddScoped<IAiUsageReportService, AiUsageReportService>();
builder.Services.AddScoped<IDashboardReadService, DashboardReadService>();
builder.Services.AddScoped<ICompanyListService, CompanyListService>();
builder.Services.AddScoped<ICompanyDataPurgeService, CompanyDataPurgeService>();
builder.Services.AddScoped<IPdfService, PdfService>();
builder.Services.AddScoped<ISeedDataService, SeedDataService>();
builder.Services.AddScoped<ICompanyConfigHealthService, CompanyConfigHealthService>();
builder.Services.AddScoped<ILedgerService, LedgerService>();
builder.Services.AddScoped<IAccountBalanceService, AccountBalanceService>();
builder.Services.AddScoped<IQuickBooksIifService, QuickBooksIifService>();
builder.Services.AddScoped<PowderCoating.Web.Services.QuickBooksOnlineService>();
builder.Services.AddScoped<IInAppNotificationService, PowderCoating.Web.Services.InAppNotificationService>();
builder.Services.AddScoped<ICsvImportService, CsvImportService>();
builder.Services.AddScoped<IMeasurementConversionService, MeasurementConversionService>();
builder.Services.AddScoped<IPlatformSettingsService, PlatformSettingsService>();
builder.Services.AddScoped<IEmailService, EmailService>();
builder.Services.AddScoped<IAdminNotificationService, AdminNotificationService>();
builder.Services.AddScoped<ISmsService, SmsService>();
builder.Services.AddScoped<INotificationService, NotificationService>();
builder.Services.AddHostedService<PaymentReminderBackgroundService>();
builder.Services.AddHostedService<SubscriptionExpiryBackgroundService>();
builder.Services.AddHostedService<AuditLogRetentionBackgroundService>();
builder.Services.AddHostedService<StripeWebhookRetentionBackgroundService>();
builder.Services.AddHostedService<SetupWizardReminderBackgroundService>();
builder.Services.AddHostedService<RecurringTransactionService>();
builder.Services.AddScoped<ISubscriptionService, SubscriptionService>();
builder.Services.AddScoped<IStripeService, StripeService>();
builder.Services.AddScoped<IStripeConnectService, StripeConnectService>();
builder.Services.AddScoped<IStorageMigrationService, StorageMigrationService>();
builder.Services.AddScoped<PowderCoating.Application.Interfaces.IAuditService, PowderCoating.Infrastructure.Data.AuditService>();
// Register caching service (singleton for better performance)
builder.Services.AddSingleton<PowderCoating.Application.Services.ICachingService, PowderCoating.Application.Services.CachingService>();
// Online user presence tracker (singleton in-memory store)
builder.Services.AddSingleton<PowderCoating.Web.Services.IOnlineUserTracker, PowderCoating.Web.Services.OnlineUserTracker>();
builder.Services.AddHealthChecks();
// Register lookup cache service (scoped because it depends on IUnitOfWork)
builder.Services.AddScoped<PowderCoating.Application.Services.ILookupCacheService, PowderCoating.Application.Services.LookupCacheService>();
// Configure AutoMapper
builder.Services.AddSingleton<IMapper>(sp =>
{
var loggerFactory = sp.GetRequiredService<ILoggerFactory>();
var config = new MapperConfiguration(cfg =>
{
cfg.AddProfile(new CompanyProfile());
cfg.AddProfile(new CustomerProfile());
cfg.AddProfile(new JobProfile());
cfg.AddProfile(new QuoteProfile());
cfg.AddProfile(new InventoryProfile());
cfg.AddProfile(new EquipmentProfile());
cfg.AddProfile(new MaintenanceProfile());
cfg.AddProfile(new CatalogProfile());
cfg.AddProfile(new VendorProfile());
cfg.AddProfile(new LookupProfile());
cfg.AddProfile(new AppointmentProfile());
cfg.AddProfile(new BugReportProfile());
cfg.AddProfile(new InvoiceProfile());
cfg.AddProfile(new AccountingProfile());
cfg.AddProfile(new PurchaseOrderProfile());
cfg.AddProfile(new PricingTierProfile());
}, loggerFactory);
return config.CreateMapper();
});
// Add SignalR for real-time updates
builder.Services.AddSignalR();
// Add session support
builder.Services.AddSession(options =>
{
options.IdleTimeout = TimeSpan.FromMinutes(30);
options.Cookie.HttpOnly = true; // Prevent JavaScript access
options.Cookie.IsEssential = true;
options.Cookie.SecurePolicy = builder.Environment.IsProduction()
? CookieSecurePolicy.Always
: CookieSecurePolicy.SameAsRequest;
//options.Cookie.SecurePolicy = CookieSecurePolicy.Always; // SECURITY: Require HTTPS
options.Cookie.SameSite = SameSiteMode.Lax; // Lax required so the session cookie survives cross-origin redirects (e.g. Stripe → PaymentSuccess)
options.Cookie.Name = ".PowderCoating.Session"; // Custom name (less predictable)
});
// Add memory cache
builder.Services.AddMemoryCache();
// Fido2/WebAuthn: no DI registration needed — PasskeyController builds a
// per-request Fido2 instance from the incoming Host header so the RPID matches
// automatically on every environment without config changes.
// Configure authorization policies for multi-tenancy
builder.Services.AddAuthorization(options =>
{
// System-level policies
options.AddPolicy("SuperAdminOnly", policy =>
policy.RequireRole(AppConstants.Roles.SuperAdmin));
// Company-level policies
options.AddPolicy("CompanyAdminOnly", policy =>
policy.RequireAssertion(context =>
{
var user = context.User;
var companyRole = user.FindFirst("CompanyRole")?.Value;
return companyRole == AppConstants.CompanyRoles.CompanyAdmin ||
user.IsInRole(AppConstants.Roles.SuperAdmin);
}));
options.AddPolicy("CanManageJobs", policy =>
policy.RequireAssertion(context =>
{
var user = context.User;
// SuperAdmins always have access
if (user.IsInRole(AppConstants.Roles.SuperAdmin))
return true;
// Check for specific permission claim
return user.HasClaim("Permission", "ManageJobs");
}));
options.AddPolicy("CanManageInventory", policy =>
policy.RequireAssertion(context =>
{
var user = context.User;
if (user.IsInRole(AppConstants.Roles.SuperAdmin))
return true;
return user.HasClaim("Permission", "ManageInventory");
}));
options.AddPolicy("CanManageCustomers", policy =>
policy.RequireAssertion(context =>
{
var user = context.User;
if (user.IsInRole(AppConstants.Roles.SuperAdmin))
return true;
return user.HasClaim("Permission", "ManageCustomers");
}));
options.AddPolicy("CanCreateQuotes", policy =>
policy.RequireAssertion(context =>
{
var user = context.User;
if (user.IsInRole(AppConstants.Roles.SuperAdmin))
return true;
return user.HasClaim("Permission", "CreateQuotes");
}));
options.AddPolicy("CanManageCalendar", policy =>
policy.RequireAssertion(context =>
{
var user = context.User;
if (user.IsInRole(AppConstants.Roles.SuperAdmin))
return true;
return user.HasClaim("Permission", "ManageCalendar");
}));
options.AddPolicy("CanViewCalendar", policy =>
policy.RequireAssertion(context =>
{
var user = context.User;
if (user.IsInRole(AppConstants.Roles.SuperAdmin))
return true;
return user.HasClaim("Permission", "ViewCalendar");
}));
options.AddPolicy("CanManageProducts", policy =>
policy.RequireAssertion(context =>
{
var user = context.User;
if (user.IsInRole(AppConstants.Roles.SuperAdmin))
return true;
return user.HasClaim("Permission", "ManageProducts");
}));
options.AddPolicy("CanViewProducts", policy =>
policy.RequireAssertion(context =>
{
var user = context.User;
if (user.IsInRole(AppConstants.Roles.SuperAdmin))
return true;
return user.HasClaim("Permission", "ViewProducts");
}));
options.AddPolicy("CanManageEquipment", policy =>
policy.RequireAssertion(context =>
{
var user = context.User;
if (user.IsInRole(AppConstants.Roles.SuperAdmin))
return true;
return user.HasClaim("Permission", "ManageEquipment");
}));
options.AddPolicy("CanManageVendors", policy =>
policy.RequireAssertion(context =>
{
var user = context.User;
if (user.IsInRole(AppConstants.Roles.SuperAdmin))
return true;
var companyRole = user.FindFirst("CompanyRole")?.Value;
if (companyRole == AppConstants.CompanyRoles.Accountant)
return true;
return user.HasClaim("Permission", "ManageVendors");
}));
options.AddPolicy("CanManagePurchaseOrders", policy =>
policy.RequireAssertion(context =>
{
var user = context.User;
if (user.IsInRole(AppConstants.Roles.SuperAdmin))
return true;
var companyRole = user.FindFirst("CompanyRole")?.Value;
if (companyRole == AppConstants.CompanyRoles.CompanyAdmin ||
companyRole == AppConstants.CompanyRoles.Manager ||
companyRole == AppConstants.CompanyRoles.Accountant)
return true;
return user.HasClaim("Permission", "ManageInventory") ||
user.HasClaim("Permission", "ManageVendors");
}));
options.AddPolicy("CanManageMaintenance", policy =>
policy.RequireAssertion(context =>
{
var user = context.User;
if (user.IsInRole(AppConstants.Roles.SuperAdmin))
return true;
return user.HasClaim("Permission", "ManageMaintenance");
}));
options.AddPolicy("CanManageInvoices", policy =>
policy.RequireAssertion(context =>
{
var user = context.User;
if (user.IsInRole(AppConstants.Roles.SuperAdmin))
return true;
var companyRole = user.FindFirst("CompanyRole")?.Value;
if (companyRole == AppConstants.CompanyRoles.CompanyAdmin ||
companyRole == AppConstants.CompanyRoles.Manager ||
companyRole == AppConstants.CompanyRoles.Accountant)
return true;
return user.HasClaim("Permission", "ManageInvoices") ||
user.HasClaim("Permission", "ManageJobs");
}));
options.AddPolicy("CanViewReports", policy =>
policy.RequireAssertion(context =>
{
var user = context.User;
if (user.IsInRole(AppConstants.Roles.SuperAdmin))
return true;
var companyRole = user.FindFirst("CompanyRole")?.Value;
if (companyRole == AppConstants.CompanyRoles.CompanyAdmin ||
companyRole == AppConstants.CompanyRoles.Manager ||
companyRole == AppConstants.CompanyRoles.Accountant)
return true;
return user.HasClaim("Permission", "ViewReports");
}));
options.AddPolicy("CanManageBills", policy =>
policy.RequireAssertion(context =>
{
var user = context.User;
if (user.IsInRole(AppConstants.Roles.SuperAdmin))
return true;
var companyRole = user.FindFirst("CompanyRole")?.Value;
if (companyRole == AppConstants.CompanyRoles.CompanyAdmin ||
companyRole == AppConstants.CompanyRoles.Manager ||
companyRole == AppConstants.CompanyRoles.Accountant)
return true;
return user.HasClaim("Permission", "ManageBills");
}));
options.AddPolicy("CanManageAccounting", policy =>
policy.RequireAssertion(context =>
{
var user = context.User;
if (user.IsInRole(AppConstants.Roles.SuperAdmin))
return true;
var companyRole = user.FindFirst("CompanyRole")?.Value;
if (companyRole == AppConstants.CompanyRoles.CompanyAdmin ||
companyRole == AppConstants.CompanyRoles.Manager ||
companyRole == AppConstants.CompanyRoles.Accountant)
return true;
return user.HasClaim("Permission", "ManageAccounting");
}));
options.AddPolicy("CanManageUsers", policy =>
policy.RequireAssertion(context =>
{
var user = context.User;
var companyRole = user.FindFirst("CompanyRole")?.Value;
return companyRole == AppConstants.CompanyRoles.CompanyAdmin ||
user.IsInRole(AppConstants.Roles.SuperAdmin);
}));
options.AddPolicy("CanViewData", policy =>
policy.RequireAssertion(context =>
{
// All authenticated company users can view
return context.User.Identity?.IsAuthenticated == true;
}));
});
// ── Rate Limiting ─────────────────────────────────────────────────────────────
// Per-IP partitioned policies using RateLimitPartition (ASP.NET Core 8 built-in).
// auth — login / password-reset: 10 req/min per IP
// registration — new account sign-up: 5 req/5 min per IP
// public — payment portal, quote approval: 30 req/min per IP
// webhook — Stripe/Twilio server-to-server: 200 req/min per IP
// ai — AI endpoints: 15 req/min per user (cost protection)
// authenticated— all logged-in users: 300 req/min per user
builder.Services.AddRateLimiter(options =>
{
options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
// login / password-reset — 10 per minute per IP
options.AddPolicy(AppConstants.RateLimitPolicies.Auth, ctx =>
RateLimitPartition.GetSlidingWindowLimiter(
ctx.Connection.RemoteIpAddress?.ToString() ?? "unknown",
_ => new SlidingWindowRateLimiterOptions
{
Window = TimeSpan.FromMinutes(1), SegmentsPerWindow = 4,
PermitLimit = 10, QueueLimit = 0, AutoReplenishment = true
}));
// new account registration — 5 per 5 minutes per IP
options.AddPolicy(AppConstants.RateLimitPolicies.Registration, ctx =>
RateLimitPartition.GetFixedWindowLimiter(
ctx.Connection.RemoteIpAddress?.ToString() ?? "unknown",
_ => new FixedWindowRateLimiterOptions
{
Window = TimeSpan.FromMinutes(5),
PermitLimit = 5, QueueLimit = 0, AutoReplenishment = true
}));
// payment portal + quote approval (anonymous) — 30 per minute per IP
options.AddPolicy(AppConstants.RateLimitPolicies.Public, ctx =>
RateLimitPartition.GetSlidingWindowLimiter(
ctx.Connection.RemoteIpAddress?.ToString() ?? "unknown",
_ => new SlidingWindowRateLimiterOptions
{
Window = TimeSpan.FromMinutes(1), SegmentsPerWindow = 4,
PermitLimit = 30, QueueLimit = 0, AutoReplenishment = true
}));
// Stripe/Twilio webhooks — 200 per minute per IP
options.AddPolicy(AppConstants.RateLimitPolicies.Webhook, ctx =>
RateLimitPartition.GetFixedWindowLimiter(
ctx.Connection.RemoteIpAddress?.ToString() ?? "unknown",
_ => new FixedWindowRateLimiterOptions
{
Window = TimeSpan.FromMinutes(1),
PermitLimit = 200, QueueLimit = 0, AutoReplenishment = true
}));
// AI endpoints — 15 per minute per user/IP
options.AddPolicy(AppConstants.RateLimitPolicies.Ai, ctx =>
{
var key = ctx.User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value
?? ctx.Connection.RemoteIpAddress?.ToString() ?? "unknown";
return RateLimitPartition.GetSlidingWindowLimiter(key,
_ => new SlidingWindowRateLimiterOptions
{
Window = TimeSpan.FromMinutes(1), SegmentsPerWindow = 4,
PermitLimit = 15, QueueLimit = 0, AutoReplenishment = true
});
});
// Authenticated users — lenient ceiling
options.AddPolicy(AppConstants.RateLimitPolicies.Authenticated, ctx =>
{
var key = ctx.User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value
?? ctx.Connection.RemoteIpAddress?.ToString() ?? "unknown";
return RateLimitPartition.GetSlidingWindowLimiter(key,
_ => new SlidingWindowRateLimiterOptions
{
Window = TimeSpan.FromMinutes(1), SegmentsPerWindow = 4,
PermitLimit = 300, QueueLimit = 0, AutoReplenishment = true
});
});
});
builder.Services.AddScoped<PowderCoating.Web.Filters.EnforceSuperAdmin2FAFilter>();
builder.Services.AddScoped<PowderCoating.Web.Filters.PlatformFeaturesFilter>();
builder.Services.AddControllersWithViews(options =>
{
options.Filters.AddService<PowderCoating.Web.Filters.EnforceSuperAdmin2FAFilter>();
options.Filters.AddService<PowderCoating.Web.Filters.PlatformFeaturesFilter>();
});
builder.Services.AddRazorPages();
var app = builder.Build();
// Force en-US culture so currency/number formatting is consistent on Linux hosting
var cultureInfo = new System.Globalization.CultureInfo("en-US");
System.Globalization.CultureInfo.DefaultThreadCurrentCulture = cultureInfo;
System.Globalization.CultureInfo.DefaultThreadCurrentUICulture = cultureInfo;
// SECURITY: Add security headers middleware
app.Use(async (context, next) =>
{
// Prevent clickjacking
context.Response.Headers.Append("X-Frame-Options", "DENY");
// Prevent MIME type sniffing
context.Response.Headers.Append("X-Content-Type-Options", "nosniff");
// Enable XSS protection (for older browsers)
context.Response.Headers.Append("X-XSS-Protection", "1; mode=block");
// Strict Transport Security (HSTS) — always emit; browsers ignore it over plain HTTP anyway
context.Response.Headers.Append("Strict-Transport-Security", "max-age=31536000; includeSubDomains");
// Content Security Policy — development allows unsafe-eval for hot-reload; production does not
var cspScriptSrc = app.Environment.IsDevelopment()
? "'self' 'unsafe-inline' 'unsafe-eval' https://cdn.jsdelivr.net https://code.jquery.com https://js.stripe.com"
: "'self' 'unsafe-inline' https://cdn.jsdelivr.net https://code.jquery.com https://js.stripe.com";
var cspConnectSrc = app.Environment.IsDevelopment()
? "'self' wss://localhost:* https://cdn.jsdelivr.net https://api.stripe.com" // Allow hot reload WebSocket in dev
: "'self' https://cdn.jsdelivr.net https://api.stripe.com";
context.Response.Headers.Append("Content-Security-Policy",
"default-src 'self'; " +
$"script-src {cspScriptSrc}; " +
"style-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net https://fonts.googleapis.com; " +
"font-src 'self' https://fonts.gstatic.com https://cdn.jsdelivr.net; " +
"img-src 'self' data: https:; " +
$"connect-src {cspConnectSrc}; " +
"frame-src https://js.stripe.com https://hooks.stripe.com");
// Referrer Policy - control referrer information
context.Response.Headers.Append("Referrer-Policy", "strict-origin-when-cross-origin");
// Permissions Policy - disable unnecessary browser features
context.Response.Headers.Append("Permissions-Policy",
"geolocation=(), microphone=(), camera=()");
await next();
});
// Add Serilog request logging
// 4xx and 5xx responses are elevated to Warning/Error so they clear the Warning+ AI sink filter.
// Successful requests (< 400) remain at Information and are not sent to Application Insights,
// keeping ingestion volume (and cost) low.
app.UseSerilogRequestLogging(options =>
{
options.GetLevel = (httpContext, elapsed, ex) =>
{
if (ex != null || httpContext.Response.StatusCode >= 500)
return Serilog.Events.LogEventLevel.Error;
if (httpContext.Response.StatusCode >= 400)
return Serilog.Events.LogEventLevel.Warning;
return Serilog.Events.LogEventLevel.Information;
};
options.EnrichDiagnosticContext = (diagnosticContext, httpContext) =>
{
diagnosticContext.Set("UserName", httpContext.User.Identity?.Name ?? "Anonymous");
diagnosticContext.Set("UserAgent", httpContext.Request.Headers["User-Agent"].ToString());
diagnosticContext.Set("RemoteIP", httpContext.Connection.RemoteIpAddress?.ToString());
var companyId = httpContext.User.FindFirst("CompanyId")?.Value;
if (!string.IsNullOrEmpty(companyId))
diagnosticContext.Set("CompanyId", companyId);
};
});
// Configure the HTTP request pipeline
// Forwarded headers must be first — Azure App Service (and any reverse proxy) sends
// X-Forwarded-Proto so Request.IsHttps and scheme detection work correctly.
app.UseForwardedHeaders(new ForwardedHeadersOptions
{
ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto,
KnownNetworks = { },
KnownProxies = { }
});
if (app.Environment.IsDevelopment())
{
app.UseMigrationsEndPoint();
}
else
{
app.UseExceptionHandler("/Home/Error");
app.UseHsts();
}
app.UseHttpsRedirection();
app.UseStaticFiles();
app.UseRouting();
app.UseRateLimiter();
app.UseAuthentication();
app.UseAuthorization();
// Stamp LastLoginDate for any sign-in path not covered by explicit stamps (e.g. LoginWith2fa).
app.UseMiddleware<PowderCoating.Web.Middleware.LoginTrackingMiddleware>();
// Subscription enforcement middleware (after auth, before session)
app.UseMiddleware<PowderCoating.Web.Middleware.SubscriptionMiddleware>();
app.UseSession();
// Force first-login password change before any other page
app.UseMiddleware<PowderCoating.Web.Middleware.MustChangePasswordMiddleware>();
// Track authenticated user presence (throttled, in-memory)
app.UseMiddleware<PowderCoating.Web.Middleware.OnlineUserMiddleware>();
// Kiosk intake steps use /Kiosk/Intake/{token}/{action} so the token is a path segment
app.MapControllerRoute(
name: "kiosk_intake",
pattern: "Kiosk/Intake/{token}/{action}",
defaults: new { controller = "Kiosk" });
app.MapControllerRoute(
name: "default",
pattern: "{controller=Home}/{action=Index}/{id?}");
app.MapRazorPages();
// Map SignalR hubs
app.MapHub<PowderCoating.Web.Hubs.NotificationHub>("/hubs/notifications");
app.MapHub<PowderCoating.Web.Hubs.ShopHub>("/hubs/shop");
app.MapHub<PowderCoating.Web.Hubs.KioskHub>("/hubs/kiosk");
app.MapHealthChecks("/health");
// NOTE: Automatic seeding has been disabled.
// SuperAdmins can manually seed data via Platform Management > Seed Data
// To re-enable automatic seeding, uncomment the code below:
/*
// Seed database with initial data
using (var scope = app.Services.CreateScope())
{
var services = scope.ServiceProvider;
try
{
var context = services.GetRequiredService<ApplicationDbContext>();
var userManager = services.GetRequiredService<UserManager<ApplicationUser>>();
var roleManager = services.GetRequiredService<RoleManager<IdentityRole>>();
// Seed roles and admin user
await SeedData.InitializeAsync(services, userManager, roleManager);
}
catch (Exception ex)
{
var logger = services.GetRequiredService<ILogger<Program>>();
logger.LogError(ex, "An error occurred while seeding the database.");
}
}
*/
// Auto-apply pending EF migrations disabled — migrations are applied manually via SQL script before deployment.
// if (!app.Environment.IsDevelopment())
// {
// using var scope = app.Services.CreateScope();
// try
// {
// var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
// var pending = db.Database.GetPendingMigrations().ToList();
// if (pending.Count > 0)
// {
// Log.Information("Applying {Count} pending migration(s): {Migrations}", pending.Count, string.Join(", ", pending));
// db.Database.Migrate();
// Log.Information("Database migrations applied successfully");
// }
// }
// catch (Exception ex)
// {
// Log.Fatal(ex, "Failed to apply database migrations — application cannot start");
// throw;
// }
// }
// Auto-seed system data (roles + default company + SuperAdmin users) on first run.
// Runs whenever the AspNetUsers table is empty, regardless of environment.
// All seed methods are idempotent so this is safe to leave enabled permanently.
using (var scope = app.Services.CreateScope())
{
try
{
var userManager = scope.ServiceProvider.GetRequiredService<UserManager<ApplicationUser>>();
var userCount = await userManager.Users.CountAsync();
if (userCount == 0)
{
Log.Information("No users found — running first-run system data seed");
var seedService = scope.ServiceProvider.GetRequiredService<ISeedDataService>();
var seedResult = await seedService.SeedSystemDataAsync();
if (seedResult.Success)
Log.Information("First-run seed complete: {Message}", seedResult.Message);
else
Log.Warning("First-run seed reported issues: {Message}", seedResult.Message);
}
}
catch (Exception ex)
{
Log.Error(ex, "Error during first-run system data seed");
}
}
// Auto-seed subscription plan configs (global config, always ensure present)
using (var scope = app.Services.CreateScope())
{
try
{
var context = scope.ServiceProvider.GetRequiredService<PowderCoating.Infrastructure.Data.ApplicationDbContext>();
if (!context.Database.GetPendingMigrations().Any())
{
// Default configs for all plans (used on fresh installs or to add missing plans)
var defaults = new[]
{
new PowderCoating.Core.Entities.SubscriptionPlanConfig
{
Plan = 3, // Starter
DisplayName = "Starter", Description = "Try it out — ideal for solo operators or very small shops",
MaxUsers = 1, MaxActiveJobs = 1, MaxCustomers = 25, MaxQuotes = 5, MaxCatalogItems = 10, MaxJobPhotos = 0,
MonthlyPrice = 19.99m, AnnualPrice = 199m, IsActive = true, SortOrder = 1,
CompanyId = 0, CreatedAt = DateTime.UtcNow
},
new PowderCoating.Core.Entities.SubscriptionPlanConfig
{
Plan = 0, // Basic
DisplayName = "Basic", Description = "Perfect for small shops",
MaxUsers = 3, MaxActiveJobs = 50, MaxCustomers = 100, MaxQuotes = 50, MaxCatalogItems = 100, MaxJobPhotos = 1,
MonthlyPrice = 29m, AnnualPrice = 290m, IsActive = true, SortOrder = 2,
CompanyId = 0, CreatedAt = DateTime.UtcNow
},
new PowderCoating.Core.Entities.SubscriptionPlanConfig
{
Plan = 1, // Pro
DisplayName = "Pro", Description = "For growing businesses",
MaxUsers = 10, MaxActiveJobs = 250, MaxCustomers = 1000, MaxQuotes = 500, MaxCatalogItems = 500, MaxJobPhotos = 3,
MonthlyPrice = 79m, AnnualPrice = 790m, IsActive = true, SortOrder = 3,
CompanyId = 0, CreatedAt = DateTime.UtcNow
},
new PowderCoating.Core.Entities.SubscriptionPlanConfig
{
Plan = 2, // Enterprise
DisplayName = "Enterprise", Description = "Unlimited capacity",
MaxUsers = -1, MaxActiveJobs = -1, MaxCustomers = -1, MaxQuotes = -1, MaxCatalogItems = -1, MaxJobPhotos = -1,
MonthlyPrice = 199m, AnnualPrice = 1990m, IsActive = true, SortOrder = 4,
CompanyId = 0, CreatedAt = DateTime.UtcNow
}
};
var existingPlans = context.SubscriptionPlanConfigs.IgnoreQueryFilters()
.Select(c => c.Plan).ToHashSet();
var toAdd = defaults.Where(d => !existingPlans.Contains(d.Plan)).ToList();
if (toAdd.Any())
{
context.SubscriptionPlanConfigs.AddRange(toAdd);
context.SaveChanges();
Log.Information("Seeded {Count} subscription plan configuration(s)", toAdd.Count);
}
}
}
catch (Exception ex)
{
Log.Warning(ex, "Could not auto-seed subscription plan configs (may not be initialized yet)");
}
}
// ── Startup configuration validation ─────────────────────────────────────────
// Fail fast with a clear message rather than a cryptic runtime error later.
ValidateRequiredConfiguration(app.Configuration, app.Environment);
// ── Data access architecture enforcement ─────────────────────────────────────
// Throws at startup if any non-exempt controller injects ApplicationDbContext directly.
// This is the Phase 4 gate: the app cannot start with a violation.
EnforceDataAccessArchitecture();
try
{
Log.Information("Starting web application");
app.Run();
}
catch (Exception ex)
{
Log.Fatal(ex, "Application terminated unexpectedly");
throw;
}
finally
{
Log.Information("Application shutting down");
Log.CloseAndFlush();
}
// ── Startup configuration validation ─────────────────────────────────────────────
static void ValidateRequiredConfiguration(IConfiguration config, IWebHostEnvironment env)
{
var errors = new List<string>();
// Connection string — required in all environments
if (string.IsNullOrWhiteSpace(config.GetConnectionString("DefaultConnection")))
errors.Add("ConnectionStrings:DefaultConnection is missing.");
// In production, secrets must come from Key Vault / environment — not placeholder values
if (env.IsProduction())
{
var anthropicKey = config["AI:Anthropic:ApiKey"];
if (string.IsNullOrWhiteSpace(anthropicKey) || anthropicKey.StartsWith("your-"))
Log.Warning("STARTUP: AI:Anthropic:ApiKey is not configured — AI features will be disabled.");
var sendGridKey = config["SendGrid:ApiKey"];
if (string.IsNullOrWhiteSpace(sendGridKey) || sendGridKey.StartsWith("SG.your-"))
Log.Warning("STARTUP: SendGrid:ApiKey is not configured — email notifications will be disabled.");
var stripeSecret = config["Stripe:SecretKey"];
if (string.IsNullOrWhiteSpace(stripeSecret) || stripeSecret.StartsWith("sk_test_your"))
Log.Warning("STARTUP: Stripe:SecretKey is not configured — payment processing will be disabled.");
var stripeWebhookSecret = config["Stripe:WebhookSecret"];
if (string.IsNullOrWhiteSpace(stripeWebhookSecret))
errors.Add("Stripe:WebhookSecret is required in production — webhook signature verification will fail.");
var storageConn = config["Storage:ConnectionString"];
if (string.IsNullOrWhiteSpace(storageConn))
Log.Warning("STARTUP: Storage:ConnectionString is not configured — file uploads to Azure Blob will be disabled.");
}
if (errors.Count > 0)
{
foreach (var error in errors)
Log.Fatal("STARTUP CONFIGURATION ERROR: {Error}", error);
throw new InvalidOperationException(
$"Application cannot start due to {errors.Count} configuration error(s):\n" +
string.Join("\n", errors));
}
}
// ── Data access architecture enforcement ─────────────────────────────────────────
/// <summary>
/// Scans every Controller subclass in the Web assembly at startup and throws if any
/// non-exempt controller declares ApplicationDbContext as a constructor parameter.
/// This enforces the rule defined in docs/DATA_ACCESS_ARCHITECTURE.md — if a developer
/// adds a new controller that injects ApplicationDbContext directly, the app will refuse
/// to start with a clear message naming the violator.
/// </summary>
static void EnforceDataAccessArchitecture()
{
// Controllers in this set are documented permanent exceptions — see DATA_ACCESS_ARCHITECTURE.md.
var permanentExceptions = new HashSet<string>
{
"StripeWebhookController",
"WebhooksController",
"PaymentController",
"RegistrationController",
"DataExportController",
"AccountDataExportController",
"DataPurgeController",
"SystemInfoController",
"SystemLogsController",
"CompanyHealthController",
"PasskeyController",
"AuditLogController",
"UserActivityController",
"EmailBroadcastController",
"RevenueController",
"StripeEventsController",
"SubscriptionManagementController",
"UsageQuotaController",
};
var violators = typeof(Program).Assembly.GetTypes()
.Where(t => t.IsClass && !t.IsAbstract
&& typeof(Microsoft.AspNetCore.Mvc.Controller).IsAssignableFrom(t)
&& !permanentExceptions.Contains(t.Name))
.Where(t => t.GetConstructors()
.Any(ctor => ctor.GetParameters()
.Any(p => p.ParameterType == typeof(ApplicationDbContext))))
.Select(t => t.Name)
.OrderBy(n => n)
.ToList();
if (violators.Count == 0) return;
var names = string.Join(", ", violators);
throw new InvalidOperationException(
$"DATA ACCESS VIOLATION — {violators.Count} controller(s) inject ApplicationDbContext directly " +
$"and are not in the permanent exceptions list.\n" +
$"Violators: {names}\n" +
$"Fix: route data access through IUnitOfWork. " +
$"To add a permanent exception, update both the controller comment and docs/DATA_ACCESS_ARCHITECTURE.md.");
}
// ── Serilog DB sink column configuration ─────────────────────────────────────────
static ColumnOptions BuildLogColumnOptions()
{
var cols = new ColumnOptions();
// Keep only the standard columns we care about
cols.Store.Remove(StandardColumn.Properties); // raw XML — we use AdditionalColumns instead
cols.Store.Remove(StandardColumn.MessageTemplate);
cols.Store.Add(StandardColumn.LogEvent); // full JSON blob for advanced querying
// Promote enriched properties to their own searchable columns
cols.AdditionalColumns = new List<SqlColumn>
{
new SqlColumn { ColumnName = "SourceContext", DataType = System.Data.SqlDbType.NVarChar, DataLength = 512, AllowNull = true },
new SqlColumn { ColumnName = "UserName", DataType = System.Data.SqlDbType.NVarChar, DataLength = 256, AllowNull = true },
new SqlColumn { ColumnName = "CompanyId", DataType = System.Data.SqlDbType.Int, AllowNull = true },
new SqlColumn { ColumnName = "RemoteIP", DataType = System.Data.SqlDbType.NVarChar, DataLength = 64, AllowNull = true },
};
cols.TimeStamp.ColumnName = "Timestamp";
cols.TimeStamp.ConvertToUtc = true;
return cols;
}