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,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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user