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
+499
View File
@@ -0,0 +1,499 @@
@page "/scheduled-reports"
@attribute [Microsoft.AspNetCore.Authorization.Authorize]
@inject IUserContextAccessor UserContext
@inject SharepointToolbox.Web.Infrastructure.Persistence.ScheduledReportRepository ScheduleRepo
@inject SharepointToolbox.Web.Infrastructure.Persistence.ProfileRepository ProfileRepo
@inject SharepointToolbox.Web.Services.Reports.IScheduledReportRunner Runner
@inject SharepointToolbox.Web.Services.Reports.ScheduledRunCoordinator Coordinator
@inject TranslationSource T
@rendermode InteractiveServer
@using SharepointToolbox.Web.Core.Models
@using SharepointToolbox.Web.Services.Export
<h1 class="page-title">Scheduled Reports</h1>
<p class="page-subtitle">Automatic report generation per client. Generated files appear under Reports and are downloadable there.</p>
@if (UserContext.Role != UserRole.Admin)
{
<div class="alert alert-info">Only administrators can manage scheduled reports.</div>
return;
}
@if (!string.IsNullOrEmpty(_pageMsg)) { <div class="alert alert-info" style="margin-bottom:12px">@_pageMsg</div> }
@if (_appOnlyProfiles.Count == 0)
{
<div class="alert alert-error">
No client has app-only access enabled. Open a client under <a href="/profiles">Client Profiles</a>,
enable scheduled reports, and upload its certificate first.
</div>
}
<div class="flex-row" style="margin-bottom:16px">
<button class="btn btn-primary" @onclick="AddNew" disabled="@(_appOnlyProfiles.Count == 0)">New schedule</button>
<div class="spacer"></div>
@if (Coordinator.IsPaused)
{
<span class="chip chip-blue" style="align-self:center">Scheduler paused</span>
<button class="btn btn-secondary btn-sm" @onclick="ResumeScheduler">Resume scheduler</button>
}
else
{
<button class="btn btn-secondary btn-sm" @onclick="PauseScheduler">Pause scheduler</button>
}
<button class="btn btn-danger btn-sm" @onclick="StopAll">Stop all running</button>
</div>
@if (_schedules.Count == 0 && !_showForm)
{
<div class="alert alert-info">No schedules defined.</div>
}
@foreach (var s in _schedules)
{
<div class="card">
<div class="flex-row">
<div>
<div style="font-weight:600;font-size:15px">
@(string.IsNullOrEmpty(s.Name) ? "(unnamed)" : s.Name)
@if (!s.Enabled) { <span class="chip chip-blue" style="margin-left:6px">Disabled</span> }
</div>
<div class="text-muted">@ClientName(s.ProfileId) · @s.Type · @s.Format · @RecurrenceSummary(s.Recurrence)</div>
<div class="text-muted">
@(s.AllSites ? "All sites" : $"{s.SiteUrls.Count} site(s)") ·
Next: @(s.NextRunUtc?.ToString("yyyy-MM-dd HH:mm 'UTC'") ?? "—") ·
Last: @(s.LastRunUtc?.ToString("yyyy-MM-dd HH:mm 'UTC'") ?? "never")
</div>
</div>
<div class="spacer"></div>
<button class="btn btn-secondary btn-sm" @onclick="() => RunNowAsync(s)" disabled="@Coordinator.IsRunning(s.Id)">
@(Coordinator.IsRunning(s.Id) ? "Running…" : "Run now")
</button>
@if (Coordinator.IsRunning(s.Id))
{
<button class="btn btn-danger btn-sm" @onclick="() => Stop(s)">Stop</button>
}
<button class="btn btn-secondary btn-sm" @onclick="() => ToggleEnabledAsync(s)">@(s.Enabled ? "Disable" : "Enable")</button>
<button class="btn btn-secondary btn-sm" @onclick="() => Edit(s)">Edit</button>
<button class="btn btn-danger btn-sm" @onclick="() => DeleteAsync(s)">Delete</button>
</div>
</div>
}
@if (_showForm)
{
<div class="card" style="border-color:var(--accent)">
<div class="card-title">@(_editing is null ? "New schedule" : "Edit schedule")</div>
@if (!string.IsNullOrEmpty(_formError)) { <div class="alert alert-error">@_formError</div> }
<div class="form-group">
<label class="form-label">Name</label>
<input class="form-input" @bind="_form.Name" placeholder="e.g. Weekly permissions audit" />
</div>
<div class="form-row">
<div class="form-group" style="flex:1">
<label class="form-label">Client</label>
<select class="form-input" @bind="_form.ProfileId">
@foreach (var p in _appOnlyProfiles)
{
<option value="@p.Id">@p.Name</option>
}
</select>
</div>
<div class="form-group" style="flex:1">
<label class="form-label">Report type</label>
<select class="form-input" @bind="_form.Type">
@foreach (var t in Enum.GetValues<ReportType>())
{
<option value="@t">@t</option>
}
</select>
</div>
</div>
@if (_form.Type == ReportType.VersionCleanup)
{
<div class="alert alert-error">
<strong>Destructive action.</strong> Version Cleanup permanently <strong>deletes</strong> old file
versions across the selected sites every time it runs — unattended, with no confirmation.
The report is only a summary of what was deleted. Output is HTML (no CSV).
</div>
}
<div class="form-row">
<div class="form-group" style="flex:1">
<label class="form-label">Format</label>
<select class="form-input" @bind="_form.Format" disabled="@(_form.Type == ReportType.VersionCleanup)">
@foreach (var f in Enum.GetValues<ReportFormat>())
{
<option value="@f">@f</option>
}
</select>
</div>
<div class="form-group" style="flex:1">
<label class="form-label">Multi-site bundling</label>
<select class="form-input" @bind="_form.MergeMode">
@foreach (var m in Enum.GetValues<ReportMergeMode>())
{
<option value="@m">@m</option>
}
</select>
</div>
</div>
@* ── Site scope ── *@
<div class="form-group">
<label><input type="checkbox" @bind="_form.AllSites" /> All sites in the tenant (auto-discovered)</label>
@if (!_form.AllSites)
{
<label class="form-label mt-8">Site URLs (one per line)</label>
<textarea class="form-textarea" rows="3" @bind="_siteUrlsText"
placeholder="https://contoso.sharepoint.com/sites/Marketing"></textarea>
}
</div>
@* ── Recurrence ── *@
<div class="form-row">
<div class="form-group" style="flex:1">
<label class="form-label">Frequency</label>
<select class="form-input" @bind="_form.Recurrence.Frequency">
@foreach (var f in Enum.GetValues<ReportFrequency>())
{
<option value="@f">@f</option>
}
</select>
</div>
<div class="form-group" style="flex:1">
<label class="form-label">Time (UTC, HH:mm)</label>
<input class="form-input" @bind="_form.Recurrence.TimeOfDayUtc" placeholder="06:00" />
</div>
@if (_form.Recurrence.Frequency == ReportFrequency.Weekly)
{
<div class="form-group" style="flex:1">
<label class="form-label">Day of week</label>
<select class="form-input" @bind="_form.Recurrence.DayOfWeek">
@foreach (var d in Enum.GetValues<DayOfWeek>())
{
<option value="@d">@d</option>
}
</select>
</div>
}
else if (_form.Recurrence.Frequency == ReportFrequency.Monthly)
{
<div class="form-group" style="flex:1">
<label class="form-label">Day of month</label>
<input class="form-input" type="number" min="1" max="31" @bind="_form.Recurrence.DayOfMonth" />
</div>
}
</div>
@* ── Type-specific options ── *@
<div class="form-group" style="border-top:1px solid var(--border);padding-top:12px">
<label class="form-label" style="font-weight:600">Options</label>
@switch (_form.Type)
{
case ReportType.Permissions:
<div style="display:flex;gap:16px;flex-wrap:wrap;align-items:center">
<label><input type="checkbox" @bind="_form.Options.IncludeInherited" /> Include inherited</label>
<label><input type="checkbox" @bind="_form.Options.ScanFolders" /> Scan folders</label>
<label><input type="checkbox" @bind="_form.Options.IncludeSubsites" /> Include subsites</label>
<label>Folder depth <input class="form-input" type="number" min="0" max="999" style="width:80px" @bind="_form.Options.FolderDepth" /></label>
</div>
break;
case ReportType.Storage:
<div style="display:flex;gap:16px;flex-wrap:wrap;align-items:center">
<label><input type="checkbox" @bind="_form.Options.IncludeSubsites" /> Include subsites</label>
<label><input type="checkbox" @bind="_form.Options.IncludeHiddenLibraries" /> Include hidden libraries</label>
<label><input type="checkbox" @bind="_form.Options.IncludeRecycleBin" /> Include recycle bin</label>
<label>Folder depth <input class="form-input" type="number" min="0" max="20" style="width:80px" @bind="_form.Options.FolderDepth" /></label>
</div>
break;
case ReportType.Duplicates:
<div style="display:flex;gap:16px;flex-wrap:wrap;align-items:center">
<label>Mode
<select class="form-input" style="width:120px" @bind="_form.Options.DuplicateMode">
<option value="Files">Files</option>
<option value="Folders">Folders</option>
</select>
</label>
<label><input type="checkbox" @bind="_form.Options.MatchSize" /> Match size</label>
<label><input type="checkbox" @bind="_form.Options.IncludeSubsites" /> Include subsites</label>
<label>Library <input class="form-input" style="width:160px" @bind="_form.Options.Library" placeholder="(all)" /></label>
</div>
break;
case ReportType.Search:
<div style="display:flex;gap:16px;flex-wrap:wrap;align-items:center">
<label>Extensions <input class="form-input" style="width:180px" @bind="_extensionsText" placeholder="pdf, docx" /></label>
<label>Regex <input class="form-input" style="width:180px" @bind="_form.Options.Regex" placeholder="(optional)" /></label>
<label>Max results <input class="form-input" type="number" min="1" style="width:100px" @bind="_form.Options.MaxResults" /></label>
<label>Library <input class="form-input" style="width:160px" @bind="_form.Options.Library" placeholder="(all)" /></label>
</div>
break;
case ReportType.UserAccess:
<label class="form-label">Target users (login/email, one per line)</label>
<textarea class="form-textarea" rows="2" @bind="_targetUsersText" placeholder="alice@contoso.com&#10;bob@contoso.com"></textarea>
<div style="display:flex;gap:16px;flex-wrap:wrap;align-items:center;margin-top:8px">
<label><input type="checkbox" @bind="_form.Options.IncludeInherited" /> Include inherited</label>
<label><input type="checkbox" @bind="_form.Options.IncludeSubsites" /> Include subsites</label>
</div>
break;
case ReportType.VersionCleanup:
<div style="display:flex;gap:16px;flex-wrap:wrap;align-items:center">
<label>Libraries (comma separated, blank = all) <input class="form-input" style="width:220px" @bind="_libraryTitlesText" /></label>
<label>Keep last <input class="form-input" type="number" min="0" style="width:80px" @bind="_form.Options.KeepLast" /></label>
<label><input type="checkbox" @bind="_form.Options.KeepFirst" /> Keep first version</label>
</div>
break;
}
</div>
@* ── Email delivery ── *@
<div class="form-group" style="border-top:1px solid var(--border);padding-top:12px">
<label style="font-weight:600"><input type="checkbox" @bind="_form.Email.Enabled" /> Email the report via Graph</label>
@if (_form.Email.Enabled)
{
<div class="alert alert-info" style="margin-top:8px">
Sent through the client's app-only certificate (requires the <strong>Mail.Send</strong> application
permission — re-run onboarding if the app was registered before this was added). The report file is attached.
</div>
<div class="form-group">
<label class="form-label">From (sender mailbox)</label>
<input class="form-input" @bind="_form.Email.From" placeholder="reports@contoso.com" />
</div>
<div class="form-row">
<div class="form-group" style="flex:1">
<label class="form-label">To (one per line)</label>
<textarea class="form-textarea" rows="2" @bind="_emailToText" placeholder="alice@contoso.com"></textarea>
</div>
<div class="form-group" style="flex:1">
<label class="form-label">Cc (one per line)</label>
<textarea class="form-textarea" rows="2" @bind="_emailCcText" placeholder="(optional)"></textarea>
</div>
</div>
<div class="form-group">
<label class="form-label">Subject</label>
<input class="form-input" @bind="_form.Email.Subject" />
</div>
<div class="form-group">
<label class="form-label">Body (HTML)</label>
<textarea class="form-textarea" rows="5" @bind="_form.Email.Body"></textarea>
<div class="text-muted" style="margin-top:4px">
Placeholders: {ReportName} {ClientName} {ReportType} {FileName} {DateUtc}
</div>
</div>
}
</div>
<div class="form-group">
<label><input type="checkbox" @bind="_form.Enabled" /> Enabled</label>
</div>
<div class="flex-row mt-8">
<button class="btn btn-primary" @onclick="SaveAsync">Save</button>
<button class="btn btn-secondary" @onclick="() => _showForm = false">Cancel</button>
</div>
</div>
}
@code {
private List<ScheduledReport> _schedules = new();
private List<TenantProfile> _appOnlyProfiles = new();
private bool _showForm;
private ScheduledReport? _editing;
private ScheduledReport _form = new();
private string _formError = string.Empty;
private string _pageMsg = string.Empty;
// Textarea/CSV scratch buffers mapped to/from the option lists on save.
private string _siteUrlsText = string.Empty;
private string _extensionsText = string.Empty;
private string _targetUsersText = string.Empty;
private string _libraryTitlesText = string.Empty;
private string _emailToText = string.Empty;
private string _emailCcText = string.Empty;
protected override async Task OnInitializedAsync()
{
if (UserContext.Role != UserRole.Admin) return;
await Reload();
}
private async Task Reload()
{
_schedules = (await ScheduleRepo.LoadAsync()).OrderBy(s => s.Name).ToList();
_appOnlyProfiles = (await ProfileRepo.LoadAsync()).Where(p => p.AppOnlyEnabled).OrderBy(p => p.Name).ToList();
}
private string ClientName(string profileId) =>
_appOnlyProfiles.FirstOrDefault(p => p.Id == profileId)?.Name ?? "(client removed)";
private static string RecurrenceSummary(RecurrenceRule r) => r.Frequency switch
{
ReportFrequency.Daily => $"Daily at {r.TimeOfDayUtc} UTC",
ReportFrequency.Weekly => $"Weekly {r.DayOfWeek} at {r.TimeOfDayUtc} UTC",
ReportFrequency.Monthly => $"Monthly day {r.DayOfMonth} at {r.TimeOfDayUtc} UTC",
_ => r.TimeOfDayUtc
};
private void AddNew()
{
_editing = null;
_form = new ScheduledReport
{
ProfileId = _appOnlyProfiles.FirstOrDefault()?.Id ?? string.Empty,
CreatedBy = UserContext.Email
};
_siteUrlsText = _extensionsText = _targetUsersText = _libraryTitlesText = string.Empty;
_emailToText = _emailCcText = string.Empty;
_formError = string.Empty;
_showForm = true;
}
private void Edit(ScheduledReport s)
{
_editing = s;
// Deep-ish copy so cancel discards edits.
_form = new ScheduledReport
{
Id = s.Id, ProfileId = s.ProfileId, Name = s.Name, Type = s.Type,
AllSites = s.AllSites, SiteUrls = new List<string>(s.SiteUrls),
MergeMode = s.MergeMode, Format = s.Format, Enabled = s.Enabled,
CreatedBy = s.CreatedBy, CreatedUtc = s.CreatedUtc,
LastRunUtc = s.LastRunUtc, NextRunUtc = s.NextRunUtc,
Recurrence = new RecurrenceRule
{
Frequency = s.Recurrence.Frequency, TimeOfDayUtc = s.Recurrence.TimeOfDayUtc,
DayOfWeek = s.Recurrence.DayOfWeek, DayOfMonth = s.Recurrence.DayOfMonth
},
Options = Clone(s.Options),
Email = new ReportEmailSettings
{
Enabled = s.Email.Enabled, From = s.Email.From,
To = new List<string>(s.Email.To), Cc = new List<string>(s.Email.Cc),
Subject = s.Email.Subject, Body = s.Email.Body
}
};
_siteUrlsText = string.Join("\n", s.SiteUrls);
_extensionsText = string.Join(", ", s.Options.Extensions);
_targetUsersText = string.Join("\n", s.Options.TargetUserLogins);
_libraryTitlesText = string.Join(", ", s.Options.LibraryTitles);
_emailToText = string.Join("\n", s.Email.To);
_emailCcText = string.Join("\n", s.Email.Cc);
_formError = string.Empty;
_showForm = true;
}
private static ScheduledReportOptions Clone(ScheduledReportOptions o) => new()
{
IncludeInherited = o.IncludeInherited, ScanFolders = o.ScanFolders, FolderDepth = o.FolderDepth,
IncludeSubsites = o.IncludeSubsites, PerLibrary = o.PerLibrary,
IncludeHiddenLibraries = o.IncludeHiddenLibraries, IncludePreservationHold = o.IncludePreservationHold,
IncludeListAttachments = o.IncludeListAttachments, IncludeRecycleBin = o.IncludeRecycleBin,
DuplicateMode = o.DuplicateMode, MatchSize = o.MatchSize, MatchCreated = o.MatchCreated,
MatchModified = o.MatchModified, MatchSubfolderCount = o.MatchSubfolderCount, MatchFileCount = o.MatchFileCount,
Library = o.Library, LibraryTitles = new List<string>(o.LibraryTitles), KeepLast = o.KeepLast, KeepFirst = o.KeepFirst,
Extensions = new List<string>(o.Extensions), Regex = o.Regex, MaxResults = o.MaxResults,
TargetUserLogins = new List<string>(o.TargetUserLogins)
};
private async Task SaveAsync()
{
_formError = string.Empty;
if (string.IsNullOrWhiteSpace(_form.Name)) { _formError = "Name is required."; return; }
if (string.IsNullOrWhiteSpace(_form.ProfileId)) { _formError = "Select a client."; return; }
// VersionCleanup has no CSV exporter.
if (_form.Type == ReportType.VersionCleanup) _form.Format = ReportFormat.Html;
// Map scratch buffers back to option lists.
_form.SiteUrls = _siteUrlsText.Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries).ToList();
_form.Options.Extensions = _extensionsText.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries).ToList();
_form.Options.TargetUserLogins = _targetUsersText.Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries).ToList();
_form.Options.LibraryTitles = _libraryTitlesText.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries).ToList();
if (!_form.AllSites && _form.SiteUrls.Count == 0) { _formError = "Add at least one site URL, or choose All sites."; return; }
if (_form.Type == ReportType.UserAccess && _form.Options.TargetUserLogins.Count == 0) { _formError = "User Access reports need at least one target user."; return; }
// Map email scratch buffers back to lists and validate when delivery is on.
_form.Email.To = _emailToText.Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries).ToList();
_form.Email.Cc = _emailCcText.Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries).ToList();
if (_form.Email.Enabled)
{
if (string.IsNullOrWhiteSpace(_form.Email.From)) { _formError = "Email delivery needs a sender mailbox (From)."; return; }
if (_form.Email.To.Count == 0 && _form.Email.Cc.Count == 0) { _formError = "Email delivery needs at least one To or Cc recipient."; return; }
}
// Arm the next run from now so the scheduler picks it up on the right cadence.
_form.NextRunUtc = _form.Recurrence.ComputeNextRunUtc(DateTime.UtcNow);
await ScheduleRepo.UpsertAsync(_form);
_showForm = false;
_editing = null;
await Reload();
}
private async Task ToggleEnabledAsync(ScheduledReport s)
{
s.Enabled = !s.Enabled;
if (s.Enabled && s.NextRunUtc is null) s.NextRunUtc = s.Recurrence.ComputeNextRunUtc(DateTime.UtcNow);
await ScheduleRepo.UpsertAsync(s);
await Reload();
}
private async Task DeleteAsync(ScheduledReport s)
{
await ScheduleRepo.DeleteAsync(s.Id);
await Reload();
}
private async Task RunNowAsync(ScheduledReport s)
{
// Register through the coordinator so this manual run is stoppable and can't
// overlap a scheduler-triggered run of the same schedule.
var token = Coordinator.TryBegin(s.Id, CancellationToken.None);
if (token is null) { _pageMsg = $"'{s.Name}' is already running."; return; }
_pageMsg = string.Empty;
await InvokeAsync(StateHasChanged);
try
{
var report = await Runner.RunAsync(s, token.Value);
_pageMsg = report.Status == ReportRunStatus.Success
? $"'{s.Name}' generated {report.FileName}. See Reports."
: $"'{s.Name}' failed: {report.Error}";
}
catch (OperationCanceledException) { _pageMsg = $"'{s.Name}' was stopped."; }
catch (Exception ex) { _pageMsg = $"'{s.Name}' failed: {ex.Message}"; }
finally { Coordinator.Complete(s.Id); await Reload(); }
}
private void Stop(ScheduledReport s) =>
_pageMsg = Coordinator.Cancel(s.Id)
? $"Stop signal sent to '{s.Name}'. It ends after the current site finishes."
: $"'{s.Name}' is not running.";
private void StopAll()
{
var n = Coordinator.CancelAll();
_pageMsg = n == 0 ? "No runs in progress." : $"Stop signal sent to {n} running report(s).";
}
private void PauseScheduler()
{
Coordinator.Pause();
_pageMsg = "Scheduler paused — no schedules will fire until resumed. Runs in progress keep going (Stop them individually).";
}
private void ResumeScheduler()
{
Coordinator.Resume();
_pageMsg = "Scheduler resumed.";
}
}