6d9c79ad5a
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>
298 lines
15 KiB
C#
298 lines
15 KiB
C#
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
|
|
});
|
|
}
|