Files
SharepointToolbox-Web/Services/Reports/ScheduledReportHostedService.cs
T
kawa 6d9c79ad5a Add scheduled reports + app-only cert auth; fix tenant-wide user-access audit
Feature work:
- Certificate (app-only) auth per profile: cert store, context/Graph client
  factories, automated app-registration provisioning (delegated + application
  permissions, admin consent), and a SessionManager seam that resolves the auth
  model per profile.
- Scheduled reports: repositories, hosted service/runner/coordinator, report
  pages, and email delivery (app-only Mail.Send).
- Tenant-wide user-access audit when no site is selected.

Audit fixes:
- Site enumeration: app-only discovery used Graph getAllSites (needs Graph
  Sites.Read.All the cert app lacks) and silently returned empty. Switched to
  the admin-host CSOM TenantSiteEnumerator, matching the scheduler; both auth
  models now share one enumeration path.
- Group expansion: the scan records a SharePoint group as a single principal, so
  user-centric audits found nothing for group-granted access. Resolve group
  membership (shared by audit + scheduler) and attribute it to the target user.
- M365 group claims: the resolver only recognized AAD security groups
  (c:0t.c|). Group-connected/Teams sites grant via the M365 group claim
  (c:0o.c|…|<guid>[_o]); now expanded too, resolving owners for the "_o" claim.
- Provision Directory.Read.All as an application permission so M365/AAD group
  expansion works under the cert identity.

Also: ignore data/appcerts/ (encrypted certificate key material).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 17:55:28 +02:00

131 lines
5.2 KiB
C#

using Microsoft.Extensions.Logging;
using SharepointToolbox.Web.Core.Models;
using SharepointToolbox.Web.Infrastructure.Persistence;
namespace SharepointToolbox.Web.Services.Reports;
/// <summary>
/// Background scheduler. Every <see cref="TickInterval"/> it loads the schedule
/// definitions, runs any whose <see cref="ScheduledReport.NextRunUtc"/> is due, and
/// advances their next-run stamp. Each run executes in its own DI scope (report
/// services are scoped). Due schedules run sequentially within a tick to bound the
/// load a single tenant sees; a long run simply delays the next due check.
/// </summary>
public class ScheduledReportHostedService : BackgroundService
{
private static readonly TimeSpan TickInterval = TimeSpan.FromMinutes(1);
private readonly IServiceScopeFactory _scopeFactory;
private readonly ScheduledReportRepository _repo;
private readonly ScheduledRunCoordinator _coordinator;
private readonly ILogger<ScheduledReportHostedService> _log;
public ScheduledReportHostedService(
IServiceScopeFactory scopeFactory,
ScheduledReportRepository repo,
ScheduledRunCoordinator coordinator,
ILogger<ScheduledReportHostedService> log)
{
_scopeFactory = scopeFactory;
_repo = repo;
_coordinator = coordinator;
_log = log;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
_log.LogInformation("Scheduled report service started (tick {Interval}).", TickInterval);
using var timer = new PeriodicTimer(TickInterval);
// Tick once at startup, then on every interval.
do
{
try { await TickAsync(stoppingToken); }
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) { break; }
catch (Exception ex) { _log.LogError(ex, "Scheduled report tick failed."); }
}
while (await WaitAsync(timer, stoppingToken));
_log.LogInformation("Scheduled report service stopping.");
}
private static async Task<bool> WaitAsync(PeriodicTimer timer, CancellationToken ct)
{
try { return await timer.WaitForNextTickAsync(ct); }
catch (OperationCanceledException) { return false; }
}
private async Task TickAsync(CancellationToken ct)
{
// Global pause: an admin has suspended all cadence-triggered runs. In-flight
// runs are unaffected (those are stopped individually); due schedules simply
// wait — NextRun is not advanced, so they fire once resumed.
if (_coordinator.IsPaused) return;
var now = DateTime.UtcNow;
var schedules = await _repo.LoadAsync();
foreach (var schedule in schedules)
{
ct.ThrowIfCancellationRequested();
if (!schedule.Enabled) continue;
// First time we see an enabled schedule with no next-run: arm it, don't run.
if (schedule.NextRunUtc is null)
{
schedule.NextRunUtc = schedule.Recurrence.ComputeNextRunUtc(now);
await _repo.UpsertAsync(schedule);
continue;
}
if (schedule.NextRunUtc > now) continue;
await RunOneAsync(schedule, now, ct);
}
}
private async Task RunOneAsync(ScheduledReport schedule, DateTime now, CancellationToken ct)
{
// Register the run so the UI can stop it mid-flight. The returned token trips on
// either app shutdown (ct) or an admin Stop. Null = a run is already in progress
// (e.g. a long previous run or a "Run now"); skip without advancing so it retries.
var token = _coordinator.TryBegin(schedule.Id, ct);
if (token is null)
{
_log.LogWarning("Schedule '{Name}' ({Id}) still running; skipping this tick.", schedule.Name, schedule.Id);
return;
}
try
{
using var scope = _scopeFactory.CreateScope();
var runner = scope.ServiceProvider.GetRequiredService<IScheduledReportRunner>();
await runner.RunAsync(schedule, token.Value);
}
catch (OperationCanceledException) when (ct.IsCancellationRequested)
{
throw; // app shutdown — bubble up to stop the service loop
}
catch (OperationCanceledException)
{
// Stopped via the coordinator (admin Stop / Stop all), not shutdown. Not a failure.
_log.LogInformation("Schedule '{Name}' ({Id}) was stopped.", schedule.Name, schedule.Id);
}
catch (Exception ex)
{
// RunAsync already captures report-level failures; this guards anything
// thrown outside it so one bad schedule can't stop the others advancing.
_log.LogError(ex, "Schedule '{Name}' ({Id}) failed to run.", schedule.Name, schedule.Id);
}
finally
{
_coordinator.Complete(schedule.Id);
// Advance regardless of outcome so a persistently failing (or stopped)
// schedule doesn't hot-loop every tick.
schedule.LastRunUtc = now;
schedule.NextRunUtc = schedule.Recurrence.ComputeNextRunUtc(now);
await _repo.UpsertAsync(schedule);
}
}
}