Files
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

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
});
}