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:
2026-06-08 17:55:28 +02:00
parent 1b0f4ce588
commit 6d9c79ad5a
40 changed files with 3020 additions and 269 deletions
+21
View File
@@ -0,0 +1,21 @@
using SharepointToolbox.Web.Core.Models;
namespace SharepointToolbox.Web.Services.Reports;
/// <summary>Sends a generated report as a Graph email (app-only, Mail.Send).</summary>
public interface IReportMailService
{
/// <summary>
/// Sends <paramref name="bytes"/> as an attachment to the recipients in
/// <paramref name="settings"/>, sending AS <see cref="ReportEmailSettings.From"/>.
/// Subject/body placeholders are resolved from the schedule and client.
/// </summary>
Task SendAsync(
TenantProfile profile,
ScheduledReport schedule,
ReportEmailSettings settings,
string fileName,
string mime,
byte[] bytes,
CancellationToken ct = default);
}
@@ -0,0 +1,14 @@
using SharepointToolbox.Web.Core.Models;
namespace SharepointToolbox.Web.Services.Reports;
public interface IScheduledReportRunner
{
/// <summary>
/// Generates one report for the given schedule using app-only auth, writes the
/// file under the client's exports subfolder, records it in the report index, and
/// audit-logs the run. Never throws for report-level failures — a failed run is
/// captured as a <see cref="GeneratedReport"/> with <see cref="ReportRunStatus.Failed"/>.
/// </summary>
Task<GeneratedReport> RunAsync(ScheduledReport schedule, CancellationToken ct = default);
}
+90
View File
@@ -0,0 +1,90 @@
using Microsoft.Graph.Models;
using Microsoft.Graph.Users.Item.SendMail;
using SharepointToolbox.Web.Core.Models;
using SharepointToolbox.Web.Infrastructure.Auth;
namespace SharepointToolbox.Web.Services.Reports;
/// <summary>
/// Delivers a generated report by email through Graph. Uses the client's app-only
/// (certificate) Graph client, which has no signed-in user, so it posts to
/// <c>/users/{From}/sendMail</c> — the configured sender mailbox must exist in the
/// tenant and the app registration must hold the <c>Mail.Send</c> application role.
/// </summary>
public class ReportMailService : IReportMailService
{
// Graph caps a single sendMail request at ~4 MB total; larger files need an upload
// session we don't implement. Reject early with a clear message instead of a 413.
private const long MaxAttachmentBytes = 3 * 1024 * 1024;
private readonly IAppOnlyContextFactory _appOnly;
public ReportMailService(IAppOnlyContextFactory appOnly) { _appOnly = appOnly; }
public async Task SendAsync(
TenantProfile profile,
ScheduledReport schedule,
ReportEmailSettings settings,
string fileName,
string mime,
byte[] bytes,
CancellationToken ct = default)
{
if (string.IsNullOrWhiteSpace(settings.From))
throw new InvalidOperationException("No sender mailbox (From) configured for report email.");
var to = CleanAddresses(settings.To);
var cc = CleanAddresses(settings.Cc);
if (to.Count == 0 && cc.Count == 0)
throw new InvalidOperationException("Report email has no To or Cc recipients.");
var message = new Message
{
Subject = Substitute(settings.Subject, profile, schedule, fileName),
Body = new ItemBody
{
ContentType = BodyType.Html,
Content = Substitute(settings.Body, profile, schedule, fileName)
},
ToRecipients = to.Select(Recipient).ToList(),
CcRecipients = cc.Select(Recipient).ToList(),
};
if (bytes.LongLength > MaxAttachmentBytes)
throw new InvalidOperationException(
$"Report '{fileName}' is {bytes.LongLength / 1024.0 / 1024.0:F1} MB — too large to email " +
$"(Graph sendMail limit ~{MaxAttachmentBytes / 1024 / 1024} MB).");
message.Attachments = new List<Attachment>
{
new FileAttachment
{
OdataType = "#microsoft.graph.fileAttachment",
Name = fileName,
ContentType = mime,
ContentBytes = bytes,
}
};
var graph = await _appOnly.CreateGraphClientAsync(profile, ct);
await graph.Users[settings.From].SendMail.PostAsync(
new SendMailPostRequestBody { Message = message, SaveToSentItems = false }, cancellationToken: ct);
}
private static List<string> CleanAddresses(IEnumerable<string> raw) =>
raw.Select(a => a?.Trim() ?? string.Empty)
.Where(a => a.Length > 0)
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToList();
private static Recipient Recipient(string address) =>
new() { EmailAddress = new EmailAddress { Address = address } };
private static string Substitute(string template, TenantProfile profile, ScheduledReport schedule, string fileName) =>
(template ?? string.Empty)
.Replace("{ReportName}", schedule.Name)
.Replace("{ClientName}", profile.Name)
.Replace("{ReportType}", schedule.Type.ToString())
.Replace("{FileName}", fileName)
.Replace("{DateUtc}", DateTime.UtcNow.ToString("yyyy-MM-dd HH:mm"));
}
@@ -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);
}
}
}
+297
View File
@@ -0,0 +1,297 @@
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Microsoft.SharePoint.Client;
using SharepointToolbox.Web.Core.Helpers;
using SharepointToolbox.Web.Core.Models;
using SharepointToolbox.Web.Infrastructure.Auth;
using SharepointToolbox.Web.Infrastructure.Persistence;
using SharepointToolbox.Web.Services.Export;
using AppConfiguration = SharepointToolbox.Web.Core.Models.AppConfiguration;
namespace SharepointToolbox.Web.Services.Reports;
/// <summary>
/// Drives one scheduled report end-to-end under app-only auth: resolve the client
/// profile, discover/select sites, scan each site with the matching report service,
/// merge the per-site results into a single artifact, write it to the client's
/// exports subfolder, and index + audit the run.
///
/// Report services take a plain <see cref="ClientContext"/>, so they are reused
/// verbatim; the only difference from the interactive pages is that the context is
/// produced by <see cref="IAppOnlyContextFactory"/> instead of the delegated session.
/// </summary>
public class ScheduledReportRunner : IScheduledReportRunner
{
private readonly ProfileRepository _profiles;
private readonly SettingsRepository _settings;
private readonly GeneratedReportRepository _index;
private readonly AuditRepository _audit;
private readonly IAppOnlyContextFactory _appOnly;
private readonly IReportMailService _mail;
private readonly AppConfiguration _cfg;
private readonly ILogger<ScheduledReportRunner> _log;
private readonly IPermissionsService _perm;
private readonly ISharePointGroupResolver _groupResolver;
private readonly IStorageService _storage;
private readonly IDuplicatesService _dup;
private readonly ISearchService _search;
private readonly IVersionCleanupService _version;
private readonly CsvExportService _permCsv;
private readonly HtmlExportService _permHtml;
private readonly StorageCsvExportService _storageCsv;
private readonly StorageHtmlExportService _storageHtml;
private readonly DuplicatesCsvExportService _dupCsv;
private readonly DuplicatesHtmlExportService _dupHtml;
private readonly SearchCsvExportService _searchCsv;
private readonly SearchHtmlExportService _searchHtml;
private readonly UserAccessCsvExportService _uaCsv;
private readonly UserAccessHtmlExportService _uaHtml;
private readonly VersionCleanupHtmlExportService _versionHtml;
public ScheduledReportRunner(
ProfileRepository profiles, SettingsRepository settings,
GeneratedReportRepository index, AuditRepository audit,
IAppOnlyContextFactory appOnly, IReportMailService mail, IOptions<AppConfiguration> cfg,
ILogger<ScheduledReportRunner> log,
IPermissionsService perm, ISharePointGroupResolver groupResolver,
IStorageService storage, IDuplicatesService dup,
ISearchService search, IVersionCleanupService version,
CsvExportService permCsv, HtmlExportService permHtml,
StorageCsvExportService storageCsv, StorageHtmlExportService storageHtml,
DuplicatesCsvExportService dupCsv, DuplicatesHtmlExportService dupHtml,
SearchCsvExportService searchCsv, SearchHtmlExportService searchHtml,
UserAccessCsvExportService uaCsv, UserAccessHtmlExportService uaHtml,
VersionCleanupHtmlExportService versionHtml)
{
_profiles = profiles; _settings = settings; _index = index; _audit = audit;
_appOnly = appOnly; _mail = mail; _cfg = cfg.Value; _log = log;
_perm = perm; _groupResolver = groupResolver; _storage = storage; _dup = dup; _search = search; _version = version;
_permCsv = permCsv; _permHtml = permHtml;
_storageCsv = storageCsv; _storageHtml = storageHtml;
_dupCsv = dupCsv; _dupHtml = dupHtml;
_searchCsv = searchCsv; _searchHtml = searchHtml;
_uaCsv = uaCsv; _uaHtml = uaHtml; _versionHtml = versionHtml;
}
public async Task<GeneratedReport> RunAsync(ScheduledReport schedule, CancellationToken ct = default)
{
var profile = (await _profiles.LoadAsync()).FirstOrDefault(p => p.Id == schedule.ProfileId);
if (profile is null)
return await FailAsync(schedule, profileName: "(unknown)", $"Client profile '{schedule.ProfileId}' not found.");
if (!profile.AppOnlyEnabled)
return await FailAsync(schedule, profile.Name, $"App-only reports are not enabled for client '{profile.Name}'.");
try
{
var sites = await ResolveSitesAsync(profile, schedule, ct);
if (sites.Count == 0)
return await FailAsync(schedule, profile.Name, "No sites resolved for this schedule.");
var settings = await _settings.LoadAsync();
var branding = new ReportBranding(settings.MspLogo, profile.ClientLogo);
var ts = DateTime.UtcNow.ToString("yyyyMMdd_HHmmss");
var output = await GenerateAsync(profile, schedule, sites, branding, ts, ct);
var dir = Path.Combine(_cfg.ExportsFolder, profile.Id);
Directory.CreateDirectory(dir);
var path = Path.Combine(dir, output.FileName);
await System.IO.File.WriteAllBytesAsync(path, output.Bytes, ct);
var report = new GeneratedReport
{
ProfileId = profile.Id,
ScheduledReportId = schedule.Id,
Type = schedule.Type,
Name = schedule.Name,
FileName = output.FileName,
Mime = output.Mime,
SizeBytes = output.Bytes.LongLength,
Status = ReportRunStatus.Success
};
// Optional email delivery. A delivery failure does NOT fail the report —
// the file is already on disk and indexed; we record the error on the entry.
string mailNote = "";
if (schedule.Email.Enabled)
{
try
{
await _mail.SendAsync(profile, schedule, schedule.Email,
output.FileName, output.Mime, output.Bytes, ct);
report.Emailed = true;
mailNote = $", emailed to {schedule.Email.To.Count + schedule.Email.Cc.Count} recipient(s)";
}
catch (OperationCanceledException) { throw; }
catch (Exception mex)
{
report.EmailError = mex.Message;
mailNote = $", email FAILED: {mex.Message}";
_log.LogError(mex, "Emailing report '{Name}' for '{Client}' failed", schedule.Name, profile.Name);
}
}
await _index.AddAsync(report);
await AuditAsync(profile.Name, schedule, ReportRunStatus.Success,
$"{schedule.Type} report '{output.FileName}' ({sites.Count} site(s), {output.Bytes.LongLength / 1024.0:F1} KB){mailNote}");
_log.LogInformation("Scheduled report '{Name}' for '{Client}' produced {File}",
schedule.Name, profile.Name, output.FileName);
return report;
}
catch (OperationCanceledException)
{
throw;
}
catch (Exception ex)
{
_log.LogError(ex, "Scheduled report '{Name}' for '{Client}' failed", schedule.Name, profile.Name);
return await FailAsync(schedule, profile.Name, ex.Message);
}
}
private async Task<IReadOnlyList<SiteInfo>> ResolveSitesAsync(
TenantProfile profile, ScheduledReport schedule, CancellationToken ct)
{
if (!schedule.AllSites)
return schedule.SiteUrls
.Where(u => !string.IsNullOrWhiteSpace(u))
.Select(u => new SiteInfo(u, ReportSplitHelper.DeriveSiteLabel(u)))
.ToList();
using var adminCtx = await _appOnly.CreateContextAsync(
profile, TenantSiteEnumerator.BuildAdminUrl(profile.TenantUrl), ct);
return await TenantSiteEnumerator.EnumerateAsync(adminCtx, ct);
}
private async Task<MergeOutput> GenerateAsync(
TenantProfile profile, ScheduledReport schedule, IReadOnlyList<SiteInfo> sites,
ReportBranding branding, string ts, CancellationToken ct)
{
var o = schedule.Options;
var progress = new Progress<OperationProgress>();
var mode = schedule.MergeMode;
var fmt = schedule.Format;
// Runs the same per-site scan loop for every report type. ClientContext is
// app-only and disposed per site.
async Task<List<(string Label, IReadOnlyList<T> Results)>> ScanSites<T>(
Func<ClientContext, SiteInfo, Task<IReadOnlyList<T>>> scan)
{
var bySite = new List<(string, IReadOnlyList<T>)>();
foreach (var site in sites)
{
ct.ThrowIfCancellationRequested();
using var ctx = await _appOnly.CreateContextAsync(profile, site.Url, ct);
bySite.Add((site.Title, await scan(ctx, site)));
}
return bySite;
}
switch (schedule.Type)
{
case ReportType.Permissions:
{
var opts = new ScanOptions(o.IncludeInherited, o.ScanFolders, o.FolderDepth, o.IncludeSubsites);
var bySite = await ScanSites<PermissionEntry>((ctx, _) => _perm.ScanSiteAsync(ctx, opts, progress, ct));
return ReportMergeHelper.Build(bySite, mode, "permissions", ts, fmt,
fmt == ReportFormat.Csv ? rs => _permCsv.BuildCsv(rs) : rs => _permHtml.BuildHtml(rs, branding));
}
case ReportType.Storage:
{
var opts = new StorageScanOptions(o.PerLibrary, o.IncludeSubsites, o.FolderDepth,
o.IncludeHiddenLibraries, o.IncludePreservationHold, o.IncludeListAttachments, o.IncludeRecycleBin);
var bySite = await ScanSites<StorageNode>((ctx, _) => _storage.CollectStorageAsync(ctx, opts, progress, ct));
return ReportMergeHelper.Build(bySite, mode, "storage", ts, fmt,
fmt == ReportFormat.Csv ? rs => _storageCsv.BuildCsv(rs) : rs => _storageHtml.BuildHtml(rs, branding));
}
case ReportType.Duplicates:
{
var opts = new DuplicateScanOptions(o.DuplicateMode, o.MatchSize, o.MatchCreated, o.MatchModified,
o.MatchSubfolderCount, o.MatchFileCount, o.IncludeSubsites, o.Library);
var bySite = await ScanSites<DuplicateGroup>((ctx, _) => _dup.ScanDuplicatesAsync(ctx, opts, progress, ct));
return ReportMergeHelper.Build(bySite, mode, "duplicates", ts, fmt,
fmt == ReportFormat.Csv ? rs => _dupCsv.BuildCsv(rs) : rs => _dupHtml.BuildHtml(rs, branding));
}
case ReportType.Search:
{
var bySite = await ScanSites<SearchResult>((ctx, site) =>
{
var opts = new SearchOptions(o.Extensions.ToArray(), o.Regex,
null, null, null, null, null, null, o.Library, o.MaxResults, site.Url);
return _search.SearchFilesAsync(ctx, opts, progress, ct);
});
return ReportMergeHelper.Build(bySite, mode, "search", ts, fmt,
fmt == ReportFormat.Csv ? rs => _searchCsv.BuildCsv(rs) : rs => _searchHtml.BuildHtml(rs, branding));
}
case ReportType.UserAccess:
{
var opts = new ScanOptions(o.IncludeInherited, o.ScanFolders, o.FolderDepth, o.IncludeSubsites);
var targets = o.TargetUserLogins
.Select(l => l.Trim().ToLowerInvariant())
.Where(l => l.Length > 0).ToHashSet();
var bySite = await ScanSites<UserAccessEntry>(async (ctx, site) =>
{
var permEntries = await _perm.ScanSiteAsync(ctx, opts, progress, ct);
// Expand SharePoint group membership so group-granted access is attributed to
// the target user (otherwise the scan only sees the group principal, not the user).
var groupMembers = await UserAccessAuditService.ResolveGroupMembersAsync(
_groupResolver, ctx, profile, permEntries, ct);
return UserAccessAuditService.TransformEntries(permEntries, targets, site, groupMembers).ToList();
});
return ReportMergeHelper.Build(bySite, mode, "user_audit", ts, fmt,
fmt == ReportFormat.Csv
? rs => _uaCsv.BuildCsv(rs.FirstOrDefault()?.UserDisplayName ?? "Users", rs.FirstOrDefault()?.UserLogin ?? "", rs)
: rs => _uaHtml.BuildHtml(rs, mergePermissions: false, branding: branding));
}
case ReportType.VersionCleanup:
{
// Destructive: this DELETES old file versions. No CSV exporter exists, so the
// output is always the HTML summary of what was removed.
var opts = new VersionCleanupOptions(o.LibraryTitles, o.KeepLast, o.KeepFirst);
var bySite = await ScanSites<VersionCleanupResult>((ctx, _) => _version.DeleteOldVersionsAsync(ctx, opts, progress, ct));
return ReportMergeHelper.Build(bySite, mode, "versions", ts, ReportFormat.Html,
rs => _versionHtml.BuildHtml(rs, branding));
}
default:
throw new NotSupportedException($"Report type {schedule.Type} is not supported.");
}
}
private async Task<GeneratedReport> FailAsync(ScheduledReport schedule, string profileName, string error)
{
var report = new GeneratedReport
{
ProfileId = schedule.ProfileId,
ScheduledReportId = schedule.Id,
Type = schedule.Type,
Name = schedule.Name,
Status = ReportRunStatus.Failed,
Error = error
};
await _index.AddAsync(report);
await AuditAsync(profileName, schedule, ReportRunStatus.Failed, error);
return report;
}
private Task AuditAsync(string profileName, ScheduledReport schedule, ReportRunStatus status, string details)
=> _audit.AppendAsync(new AuditEntry
{
Action = "ScheduledReport",
ClientName = profileName,
Sites = new List<string>(),
Details = $"{status}: {schedule.Name} — {details}",
UserEmail = "system",
UserDisplay = "Scheduler",
UserRole = UserRole.Admin
});
}
@@ -0,0 +1,78 @@
using System.Collections.Concurrent;
namespace SharepointToolbox.Web.Services.Reports;
/// <summary>
/// Process-wide coordinator for scheduled-report execution. Covers two operator needs:
/// • <b>Cancel an in-flight run</b> — every active run registers a linked
/// <see cref="CancellationTokenSource"/> keyed by schedule id, so the UI (or a
/// global stop) can abort a report that is currently executing, whether it was
/// started by the scheduler or by "Run now".
/// • <b>Pause future runs</b> — a global flag the background scheduler honours,
/// letting an admin suspend all cadence-triggered runs at once without toggling
/// each schedule's <c>Enabled</c> flag.
///
/// In-memory and singleton. The pause flag does NOT survive a process restart (a
/// restart resumes the scheduler); per-schedule <c>Enabled</c> flags persist and are
/// the durable way to keep a schedule off.
/// </summary>
public sealed class ScheduledRunCoordinator
{
private readonly ConcurrentDictionary<string, CancellationTokenSource> _active = new();
private volatile bool _paused;
/// <summary>True while the scheduler is globally paused (no schedules fire).</summary>
public bool IsPaused => _paused;
public void Pause() => _paused = true;
public void Resume() => _paused = false;
/// <summary>True while a run is registered for this schedule id.</summary>
public bool IsRunning(string scheduleId) => _active.ContainsKey(scheduleId);
/// <summary>Snapshot of schedule ids with a run in progress.</summary>
public IReadOnlyCollection<string> RunningIds => _active.Keys.ToArray();
/// <summary>
/// Registers a run for <paramref name="scheduleId"/> and returns a token that trips
/// when either the caller's <paramref name="linked"/> token (e.g. app shutdown) or a
/// <see cref="Cancel"/>/<see cref="CancelAll"/> call fires. Returns <c>null</c> if a
/// run is already registered for this schedule — callers must skip to avoid overlap.
/// Always pair a non-null return with <see cref="Complete"/> in a <c>finally</c>.
/// </summary>
public CancellationToken? TryBegin(string scheduleId, CancellationToken linked)
{
var cts = CancellationTokenSource.CreateLinkedTokenSource(linked);
if (!_active.TryAdd(scheduleId, cts)) { cts.Dispose(); return null; }
return cts.Token;
}
/// <summary>Deregisters and disposes the run for this schedule id.</summary>
public void Complete(string scheduleId)
{
if (_active.TryRemove(scheduleId, out var cts)) cts.Dispose();
}
/// <summary>Signals cancellation to the run for this schedule id. Returns false if none.</summary>
public bool Cancel(string scheduleId)
{
if (_active.TryGetValue(scheduleId, out var cts))
{
try { cts.Cancel(); return true; }
catch (ObjectDisposedException) { return false; } // completed between lookup and cancel
}
return false;
}
/// <summary>Signals cancellation to every run in progress. Returns the count signalled.</summary>
public int CancelAll()
{
int n = 0;
foreach (var cts in _active.Values)
{
try { cts.Cancel(); n++; }
catch (ObjectDisposedException) { /* completed concurrently */ }
}
return n;
}
}