Repository navigation
Expand file tree
/
Copy pathProgram.cs
More file actions
464 lines (421 loc) · 27.8 KB
/
Copy pathProgram.cs
File metadata and controls
464 lines (421 loc) · 27.8 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
using ExchangeAdminWeb.Authorization;
using ExchangeAdminWeb.Components;
using ExchangeAdminWeb.Middleware;
using ExchangeAdminWeb.Modules;
using ExchangeAdminWeb.Services;
using Microsoft.AspNetCore.Authentication.Negotiate;
using Microsoft.AspNetCore.Authorization;
using Serilog;
System.Text.Encoding.RegisterProvider(System.Text.CodePagesEncodingProvider.Instance);
Log.Logger = new LoggerConfiguration()
.WriteTo.Console()
.CreateBootstrapLogger();
try
{
var builder = WebApplication.CreateBuilder(args);
// Section access config is read directly by SectionAccessService (not via IConfiguration)
// to ensure fail-closed behavior on parse errors and correct override semantics.
builder.Host.UseSerilog((ctx, services, config) => config
.ReadFrom.Configuration(ctx.Configuration)
.ReadFrom.Services(services)
.Enrich.FromLogContext()
.WriteTo.Console()
.WriteTo.File("logs/app-.log", rollingInterval: RollingInterval.Day, retainedFileCountLimit: 30));
var allowedGroups = builder.Configuration.GetSection("Security:AllowedGroups").Get<string[]>() ?? Array.Empty<string>();
var adminGroups = builder.Configuration.GetSection("Security:AdminGroups").Get<string[]>() ?? Array.Empty<string>();
if (adminGroups.Length == 0)
Log.Warning("Security:AdminGroups is empty or missing - admin settings page will be inaccessible until configured");
var catalog = new ModuleCatalog();
builder.Services.AddAuthentication(NegotiateDefaults.AuthenticationScheme)
.AddNegotiate();
builder.Services.AddSingleton(catalog);
// Config store infrastructure (SqliteConfigStore-Plan Phase A). Without ConfigStore:Path the
// DB lives in the persistent, deploy-excluded config/ directory and is created on first
// start. With it (docs/SharedConfigDb-Plan.md, decision 2026-09-04) both instances on this
// server open ONE shared file that must already exist - a UNC or relative value, or a
// missing file, is fatal here rather than a silent fresh database. The factory opens
// short-lived connections (never a shared singleton connection), so it is safe across the
// mix of Singleton/Scoped consumers.
ExchangeAdminWeb.Services.Storage.ConfigStorePath.Resolution configDb;
try
{
configDb = ExchangeAdminWeb.Services.Storage.ConfigStorePath.Resolve(
builder.Configuration[ExchangeAdminWeb.Services.Storage.ConfigStorePath.Key],
builder.Environment.ContentRootPath);
}
catch (InvalidOperationException ex)
{
Log.Fatal(ex.Message);
throw;
}
var configDbPath = configDb.Path;
builder.Services.AddSingleton(new ExchangeAdminWeb.Services.Storage.SqliteConnectionFactory(configDbPath, configDb.MustExist));
builder.Services.AddSingleton<ExchangeAdminWeb.Services.Storage.ConfigStoreMigrator>();
builder.Services.AddSingleton<ExchangeAdminWeb.Services.Storage.IConfigStore,
ExchangeAdminWeb.Services.Storage.SqliteConfigStore>();
builder.Services.AddSingleton<ExchangeAdminWeb.Services.Storage.AppSettingRepository>();
builder.Services.AddSingleton<ExchangeAdminWeb.Services.Storage.ModuleAdminRepository>();
builder.Services.AddSingleton<ExchangeAdminWeb.Services.Storage.ModuleConfigRepository>();
builder.Services.AddSingleton<ExchangeAdminWeb.Services.Storage.ModuleEnablementRepository>();
builder.Services.AddSingleton<ExchangeAdminWeb.Services.Storage.SectionAccessRepository>();
builder.Services.AddSingleton<ExchangeAdminWeb.Services.Storage.ProtectedPrincipalRepository>();
builder.Services.AddSingleton<ExchangeAdminWeb.Services.Storage.AttributeEditorRepository>();
// Bulk job runner infrastructure (docs/BulkJobRunner-Plan.md). Durable server-side batches live
// in a SEPARATE operational SQLite database (exchangeadmin-jobs.db) in the same deploy-excluded
// config/ directory, distinct from the config DB: job state is environment-local, high-churn and
// MUST NEVER be promoted dev->prod (owner 2026-07-02). The repository gets its own connection
// factory pointed at that file. Processors are resolved per-job from a fresh scope via the
// registry, so the runner stays module-agnostic (no compile-time dependency on any module).
var jobsDbPath = Path.Combine(builder.Environment.ContentRootPath, "config", "exchangeadmin-jobs.db");
builder.Services.AddSingleton(new ExchangeAdminWeb.Services.Jobs.BulkJobRepository(
new ExchangeAdminWeb.Services.Storage.SqliteConnectionFactory(jobsDbPath)));
// Anonymous usage telemetry (docs/UsageTelemetry-Plan.md, AC1). A THIRD operational SQLite
// database in the same deploy-excluded config/ directory, separate from both the shared
// config DB and the jobs DB: it is disposable environment-local telemetry that is never
// promoted and never backed up (review finding ute-1), so it gets its own factory pointed
// at its own file and has no migrator - the repository creates its table idempotently.
var usageDbPath = Path.Combine(builder.Environment.ContentRootPath, "config", "exchangeadmin-usage.db");
builder.Services.AddSingleton(new ExchangeAdminWeb.Services.Storage.UsageEventRepository(
new ExchangeAdminWeb.Services.Storage.SqliteConnectionFactory(usageDbPath)));
builder.Services.AddSingleton(_ => new ExchangeAdminWeb.Services.Jobs.BulkJobProcessorRegistry(
new KeyValuePair<string, Type>[]
{
// module id -> processor type; the type is resolved per-job from a fresh scope.
new(ExchangeAdminWeb.Services.Jobs.ConferenceRoomBulkProcessor.ModuleName,
typeof(ExchangeAdminWeb.Services.Jobs.ConferenceRoomBulkProcessor)),
new(ExchangeAdminWeb.Services.Jobs.MessageTraceDetailJobProcessor.ModuleName,
typeof(ExchangeAdminWeb.Services.Jobs.MessageTraceDetailJobProcessor)),
new(ExchangeAdminWeb.Services.Jobs.MigrationReportExportProcessor.ModuleName,
typeof(ExchangeAdminWeb.Services.Jobs.MigrationReportExportProcessor)),
new(ExchangeAdminWeb.Services.Jobs.Comms10kReplaceProcessor.ModuleName,
typeof(ExchangeAdminWeb.Services.Jobs.Comms10kReplaceProcessor)),
}));
builder.Services.AddSingleton<ExchangeAdminWeb.Services.Jobs.BulkJobService>();
builder.Services.AddSingleton<ModuleEnablementService>();
builder.Services.AddSingleton<SectionAccessService>();
builder.Services.AddSingleton<IAuthorizationHandler, GroupAuthorizationHandler>();
builder.Services.AddAuthorization(options =>
{
catalog.ConfigureAuthorizationPolicies(options, allowedGroups, adminGroups);
});
builder.Services.AddCascadingAuthenticationState();
builder.Services.AddHttpContextAccessor();
builder.Services.AddRazorComponents()
.AddInteractiveServerComponents();
builder.Services.AddHttpClient("ServiceNow")
.ConfigureHttpClient(client =>
{
client.Timeout = TimeSpan.FromSeconds(30);
client.DefaultRequestHeaders.Add("Accept", "application/json");
});
builder.Services.AddHttpClient("MicrosoftGraph")
.ConfigureHttpClient(client =>
{
client.Timeout = TimeSpan.FromSeconds(30);
});
// Defender for Endpoint device inventory (docs/DefenderEndpointDevices-Plan.md S1). Its own
// named client rather than the "MicrosoftGraph" one: this module calls
// api.security.microsoft.com with a DIFFERENT token audience, and a separate registration keeps
// that separation visible here rather than buried in the service.
builder.Services.AddHttpClient(DefenderEndpointDeviceService.HttpClientName)
.ConfigureHttpClient(client =>
{
client.Timeout = TimeSpan.FromSeconds(30);
});
// The SECOND client for the same module (docs/DefenderEndpointDevices-Plan.md S4, T7), and not a
// duplicate of the one above: the advanced hunting query runs against Microsoft Graph, and Graph
// allows a single hunting request up to three minutes of its own. Reusing either 30-second
// client would cancel legitimate work and report it as an intermittent, load-dependent timeout
// that looks like a service fault. Four minutes sits above Graph's own ceiling, so the service's
// answer wins and a client-side timeout means something has genuinely hung.
builder.Services.AddHttpClient(DefenderEndpointDeviceService.HuntingHttpClientName)
.ConfigureHttpClient(client =>
{
client.Timeout = TimeSpan.FromMinutes(4);
});
// True Last Logon's cloud half (docs/TrueLastLogon-Plan.md S3). Its own client for the same
// reason the hunting client above is its own: the raw sign-in log costs roughly ten seconds
// per query and this module issues two of them, so the shared "MicrosoftGraph" client's
// thirty seconds would cancel legitimate work and report it as an intermittent fault. Two
// minutes sits well clear of the measured cost without letting a genuinely hung request sit
// there for four.
builder.Services.AddHttpClient(CloudSignInService.HttpClientName)
.ConfigureHttpClient(client =>
{
client.Timeout = TimeSpan.FromMinutes(2);
});
builder.Services.AddSingleton<ModuleConfigService>();
builder.Services.AddSingleton<ModuleCredentialService>();
builder.Services.AddSingleton<ModuleAdminService>();
builder.Services.AddSingleton<MfaResetService>();
builder.Services.AddSingleton<PasswordGenerator>();
builder.Services.AddSingleton<CloudPasswordResetService>();
builder.Services.AddSingleton<IntuneDeviceService>();
builder.Services.AddSingleton<Comms10kService>();
builder.Services.AddScoped<ConferenceRoomService>();
// Single protected-principal enforcement point for every ConferenceRooms room-mutating write
// (page Finder/Type + each bulk row). Guarded-execution: the write runs only through its
// onAllowed delegate, so the gate cannot be bypassed and is decided before any side effect.
builder.Services.AddScoped<ConferenceRoomProtectionGate>();
// The bulk job processor talks to rooms through the narrow IConferenceRoomBulkOperations seam
// (implemented by ConferenceRoomService) so it is unit-testable without live EXO/AD.
builder.Services.AddScoped<ExchangeAdminWeb.Services.Jobs.IConferenceRoomBulkOperations>(
sp => sp.GetRequiredService<ConferenceRoomService>());
// Bulk job processor for ConferenceRooms (resolved per-job from a fresh scope by the runner).
builder.Services.AddScoped<ExchangeAdminWeb.Services.Jobs.ConferenceRoomBulkProcessor>();
builder.Services.AddScoped<ExchangeAdminWeb.Services.Jobs.MigrationReportExportProcessor>();
builder.Services.AddScoped<ExchangeAdminWeb.Services.Jobs.Comms10kReplaceProcessor>();
builder.Services.AddSingleton<NamedLocationsService>();
builder.Services.AddSingleton<M365GroupManagementService>();
// Risky Users read path (docs/RiskyUsersModule-Plan.md, S2). Singleton like the other Graph
// services: no per-request state, and GraphTokenClient is constructed per operation from the
// named "MicrosoftGraph" client above.
builder.Services.AddSingleton<RiskyUsersService>();
// Service Health read path (docs/ServiceHealth-Plan.md). Singleton like the other Graph
// services, and deliberately so here: the 10-minute status cache lives on the instance, so a
// scoped registration would give every operator their own cache and defeat it.
builder.Services.AddSingleton<ServiceHealthService>();
// Defender for Endpoint device inventory read path (docs/DefenderEndpointDevices-Plan.md S1).
// Singleton like the other API-backed read services: no per-request state, and the API client is
// constructed per operation from the named client above. Nothing is user-reachable yet - the
// module descriptor and page arrive in S2.
builder.Services.AddSingleton<DefenderEndpointDeviceService>();
// True Last Logon (docs/TrueLastLogon-Plan.md). Two halves, registered together because
// neither is useful alone: the on-prem sweep is the only source that sees on-prem-only
// activity, and the cloud half is the only one that sees sign-ins no DC ever handled.
// Singletons like the other read services - no per-request state, and CloudSignInService
// builds its API client per operation from the named client above. TrueLastLogonService
// needs no credential at all; it runs read-only under the app pool identity.
builder.Services.AddSingleton<TrueLastLogonService>();
builder.Services.AddSingleton<CloudSignInService>();
builder.Services.AddSingleton<DhcpAuthorizationService>();
// BitLocker recovery. Scoped: the service opens a short-lived SQLite connection per query and
// holds no state between them. Needs no HttpClient, no Graph registration and no Exchange
// connection -- default searches read the local archive, and the optional live AD fallback
// uses ModuleCredentialService with a module-specific Delinea secret.
builder.Services.AddScoped<IBitLockerLiveDirectorySearch, PowerShellBitLockerLiveDirectorySearch>();
builder.Services.AddScoped<BitLockerRecoveryService>();
builder.Services.AddScoped<GroupManagementService>();
builder.Services.AddScoped<ExchangeAdminWeb.Services.SelfServiceGroups.SelfServiceGroupService>();
builder.Services.AddScoped<ADAttributeEditorService>();
builder.Services.AddSingleton<ADOrganizationalUnitService>();
builder.Services.AddSingleton<ADDirectorySearchService>();
// The operator-email resolver reads the same pooled AD runspace through a one-member seam,
// so it never reaches the wildcard autocomplete search (OperatorEmailResolution-Plan).
builder.Services.AddSingleton<IOperatorDirectory>(sp => sp.GetRequiredService<ADDirectorySearchService>());
builder.Services.AddSingleton<OperatorEmailResolver>();
// The section-access SID migration gets its OWN directory service rather than reusing the
// autocomplete one: it must throw when a lookup fails (a migration that reads an outage as
// "no such group" deletes live access grants), and it must not queue behind the shared
// 30-second autocomplete lock at startup. See SectionAccessSidStorage-Plan.
builder.Services.AddSingleton<ExchangeAdminWeb.Authorization.ISectionAccessGroupDirectory,
SectionAccessGroupDirectory>();
builder.Services.AddSingleton<SectionAccessSidMigration>();
builder.Services.AddScoped<EmergencyDisableService>();
builder.Services.AddScoped<AccountLockoutRemediationService>();
builder.Services.AddScoped<LicensingUpdatesService>();
builder.Services.AddScoped<DelegationReportService>();
builder.Services.AddScoped<OutOfOfficeService>();
builder.Services.AddScoped<RecipientLookupService>();
builder.Services.AddScoped<HeaderAnalysisService>();
builder.Services.AddScoped<MessageTraceService>();
// The Message Analysis detail-export bulk processor talks to detail through the narrow
// IMessageTraceDetailSource seam (implemented by MessageTraceService) so it is unit-testable
// without live EXO/on-prem, and is resolved per-job from a fresh scope by the runner.
builder.Services.AddScoped<ExchangeAdminWeb.Services.Jobs.IMessageTraceDetailSource>(
sp => sp.GetRequiredService<MessageTraceService>());
// Single owner of the export directory, filename convention, and jobId validation, shared by the
// detail-export writer and the Downloadable Reports page so the two cannot drift apart.
builder.Services.AddScoped<MessageTraceExportStore>();
builder.Services.AddScoped<MigrationReportStore>();
// Page logic for the Downloadable Reports page, kept out of the markup so it is unit-testable
// (the repo has no bUnit harness).
builder.Services.AddScoped<MessageTraceExportListing>();
builder.Services.AddScoped<ExchangeAdminWeb.Services.Jobs.MessageTraceDetailJobProcessor>();
builder.Services.AddScoped<MailboxPermissionService>();
builder.Services.AddScoped<CalendarPermissionService>();
builder.Services.AddScoped<BlockedSenderService>();
builder.Services.AddSingleton<ExtendedLogService>();
builder.Services.AddSingleton<JsonlLogService>();
// Anonymous usage telemetry (docs/UsageTelemetry-Plan.md, AC3). Registered BEFORE
// AuditService so the optional sink on its constructor is satisfied; a singleton, like the
// audit service it hangs off.
builder.Services.AddSingleton<UsageTelemetryService>();
builder.Services.AddSingleton<OperationTraceService>();
// The global progress channel (docs/GlobalProgressSystem-Plan.md). SCOPED, and it must stay
// that way: it is per-circuit operator-facing state, so a singleton registration would hand
// the first circuit's sink to every later one and report one operator's work to another.
// No singleton may take IActivityProgress as a dependency either - ActivityProgressLifetimeTests
// fails the build if one does. Pages own the handle and pass progress and the cancellation
// token down into services as arguments.
builder.Services.AddScoped<ExchangeAdminWeb.Services.Progress.IActivityProgress,
ExchangeAdminWeb.Services.Progress.ActivityProgressService>();
builder.Services.AddSingleton<PageLoadTracker>();
builder.Services.AddSingleton<AuditService>();
builder.Services.AddSingleton<EmailService>();
builder.Services.AddSingleton<ProtectedPrincipalService>();
builder.Services.AddSingleton<PermissionValidator>();
builder.Services.AddScoped<BlockedSenderProtectionGate>();
// SINGLETON, deliberately. PermissionValidator and M365GroupManagementService are singletons
// and must consult this to authorise servicing; a singleton cannot inject a scoped service,
// and forcing it creates a captive dependency that outlives its scope. This service is
// stateless and its only dependencies are SectionAccessService (already a singleton) and a
// logger, so the scoped registration was wrong rather than load-bearing.
//
// Scoped consumers keep working - BlockedSenderProtectionGate above is scoped and injects this
// one, which is always legal in that direction.
builder.Services.AddSingleton<ProtectedPrincipalServicerService>();
builder.Services.AddSingleton<ServiceNowService>();
builder.Services.AddSingleton<ITicketValidator, TicketValidationService>();
builder.Services.AddSingleton<DelineaService>();
builder.Services.AddSingleton<ExoConnectionPool>();
builder.Services.AddScoped<MigrationService>();
builder.Services.AddScoped<IIdentityResolver, ExchangeIdentityResolver>();
builder.Services.AddScoped<ClientInfoService>();
// Captures IP/user agent into the circuit-scoped ClientInfoService at circuit
// open, so audit records carry the right per-session IP for the circuit's
// whole lifetime (the static cache is fallback only).
builder.Services.AddScoped<Microsoft.AspNetCore.Components.Server.Circuits.CircuitHandler, ClientInfoCircuitHandler>();
// A throwaway per-circuit id for anonymous usage rows (docs/UsageTelemetry-Plan.md, AC4).
// Scoped like ClientInfoService above; the handler publishes it as an ambient value for the
// duration of each inbound circuit activity so the singleton telemetry service - reached
// from deep inside the audit path - can see which visit an action belonged to.
builder.Services.AddScoped<UsageSession>();
builder.Services.AddScoped<Microsoft.AspNetCore.Components.Server.Circuits.CircuitHandler, UsageSessionCircuitHandler>();
builder.Services.AddScoped<IUndoableModule, ADAttributeEditorUndoService>();
builder.Services.AddScoped<UndoRegistry>();
var app = builder.Build();
// Ensure the config database schema exists / is current before serving requests
// (SqliteConfigStore-Plan Phase A). Idempotent: a no-op once the DB is at the target
// version. Fail fast - a config store that cannot be opened/migrated is not a state we
// should serve in.
{
// Fail fast if the audit/operational log root is not configured. There is no baked-in
// default (docs/RemoveHardcodedLogRoot-Plan.md): silently misplacing audit logs is worse
// than a deploy that stops and says why. Runs before any logging service is resolved.
if (string.IsNullOrWhiteSpace(builder.Configuration["Audit:LogRoot"]))
{
Log.Fatal(ExchangeAdminWeb.Services.AuditLogRoot.UnsetMessage);
throw new InvalidOperationException(ExchangeAdminWeb.Services.AuditLogRoot.UnsetMessage);
}
var migrator = app.Services.GetRequiredService<ExchangeAdminWeb.Services.Storage.ConfigStoreMigrator>();
var schemaVersion = migrator.Migrate();
Log.Information("Config store schema ready at version {SchemaVersion}", schemaVersion);
// Startup self-registration (SqliteConfigStore-Plan Section 3d): non-destructively seed
// enablement rows for any catalog modules missing one (e.g. a newly added module), at
// their EnabledByDefault. Never overwrites existing rows - the banned destructive
// startup write stays banned. No-op on a corrupt store.
var enablement = app.Services.GetRequiredService<ModuleEnablementService>();
enablement.SeedMissingModules();
// Convert section-access group names to SIDs (docs/SectionAccessSidStorage-Plan.md).
// Runs on every start and is idempotent: a row already holding a SID is left alone, so a
// run deferred by an AD outage simply picks up later. Never throws and never half-writes -
// this is the table deciding who reaches every module, and it runs before anyone can log
// in to repair anything, so failing to start would be a worse outage than the ambiguity
// being fixed. Every failure path leaves the store exactly as it was.
// Resolve SectionAccessService FIRST. Its constructor performs the one-time import of a
// legacy config\sectionaccess.json (SectionAccessService.cs, ImportLegacyIfPresent), and
// because that is a constructor side effect on a lazily-constructed singleton, its timing
// is otherwise decided by whichever request happens to touch authorization first - i.e.
// AFTER this migration. On a legacy upgrade that leaves the table holding names for the
// whole process lifetime, which now means denying everyone configured only through that
// file until someone restarts. Review finding sid-2.
_ = app.Services.GetRequiredService<SectionAccessService>();
var sectionAccessSids = app.Services.GetRequiredService<SectionAccessSidMigration>();
var sidMigrationStatus = sectionAccessSids.Run();
Log.Information("Section-access SID migration: {Status}", sidMigrationStatus);
// One-time repair of the renamed Graph credential key (DelineaSecretId ->
// GraphDelineaSecretId): moves any value stranded under the old key for Graph modules so
// the config page (which binds only the new key) shows it. Idempotent, catalog-scoped to
// Graph modules, non-destructive. See docs/GraphSecretKeyMigration-Plan.md.
var moduleConfig = app.Services.GetRequiredService<ModuleConfigService>();
moduleConfig.MigrateGraphSecretKeys();
// Bulk job runner startup (docs/BulkJobRunner-Plan.md). A DI singleton is not constructed
// until first resolved, so this explicit call is required for orphan reconciliation to run:
// it migrates the jobs DB, prunes old terminal jobs, and - the load-bearing rule - flips
// every non-terminal job (Running OR Queued) to Interrupted. There is no resume; this is a
// one-shot startup call, NOT a background timer/hosted worker (consistent with the
// 2026-06-17 no-unattended-worker posture).
var bulkJobs = app.Services.GetRequiredService<ExchangeAdminWeb.Services.Jobs.BulkJobService>();
bulkJobs.InitializeAsync();
// Message Analysis export retention, in the same one-shot startup pass that prunes old job
// RECORDS above - the records and the files they describe now expire on the same schedule
// and by the same mechanism.
//
// This was documented for months as the job of a host scheduled task that was never
// created on any host, so nothing enforced the window (docs/AdminBulkJobs-Plan.md Part A).
// Owner ruled 2026-08-04 that there are and will be no scheduled tasks, so it lives here.
// Never throws; retention must not be able to stop the app booting.
using (var retentionScope = app.Services.CreateScope())
{
var exports = retentionScope.ServiceProvider.GetRequiredService<MessageTraceExportStore>();
var retentionLog = retentionScope.ServiceProvider
.GetRequiredService<ILogger<MessageTraceExportStore>>();
var removed = exports.PruneExpired(DateTime.UtcNow, retentionLog);
if (removed > 0)
Log.Information("Export retention: removed {Count} expired Message Analysis export(s)", removed);
// Migration reports, same pass and the same reasoning (R24d of
// docs/MigrationInterfaceRedesign-Plan.md). Their window is twelve hours rather than
// thirty days, and the store also sweeps on every write - but a store nobody opens
// between restarts would still keep yesterday's, which is what this pass is for.
var reports = retentionScope.ServiceProvider.GetRequiredService<MigrationReportStore>();
var reportsRemoved = reports.Sweep(DateTime.UtcNow);
if (reportsRemoved > 0)
Log.Information("Migration report retention: removed {Count} expired report(s)", reportsRemoved);
}
// Usage-telemetry retention, in the same one-shot startup pass (docs/UsageTelemetry-Plan.md,
// AC8). Its own try/catch rather than sharing one with the passes above: the usage database
// is disposable environment-local telemetry, and a missing, locked or unwritable file must
// never be able to stop the app booting or to hide a failure of a pass that matters.
try
{
var usageEvents = app.Services
.GetRequiredService<ExchangeAdminWeb.Services.Storage.UsageEventRepository>();
var prunedUsage = usageEvents.PruneOlderThan(
DateTime.UtcNow.AddDays(-UsageTelemetryService.RetentionDays));
if (prunedUsage > 0)
{
Log.Information(
"Usage telemetry retention: removed {Count} usage event(s) older than {Days} days",
prunedUsage,
UsageTelemetryService.RetentionDays);
}
}
catch (Exception ex)
{
Log.Warning(ex, "Usage telemetry retention pass failed; old usage rows remain");
}
}
var pathBase = (builder.Configuration["Application:PathBase"] ?? "/ExchangeAdminWeb").TrimEnd('/');
if (string.IsNullOrWhiteSpace(pathBase) || pathBase == "/")
pathBase = "";
if (pathBase.Length > 0)
app.UsePathBase(pathBase);
if (!app.Environment.IsDevelopment())
{
app.UseExceptionHandler("/Error", createScopeForErrors: true);
app.UseHsts();
}
app.UseHttpsRedirection();
app.UseStaticFiles();
app.UseAuthentication();
app.UseAuthorization();
app.UseMiddleware<ClientInfoMiddleware>();
// After authentication, so the request is attributed to the right operator, and after
// ClientInfoMiddleware for consistency with it. Records page requests while they are being
// served so the status frame reports a fact rather than inferring one in the browser.
app.UseMiddleware<PageLoadMiddleware>();
app.UseAntiforgery();
app.MapRazorComponents<App>()
.AddInteractiveServerRenderMode()
.RequireAuthorization();
app.Run();
}
catch (Exception ex)
{
Log.Fatal(ex, "Application terminated unexpectedly");
}
finally
{
Log.CloseAndFlush();
}