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>
This commit is contained in:
@@ -0,0 +1,130 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user