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
+101
View File
@@ -0,0 +1,101 @@
@page "/reports"
@attribute [Microsoft.AspNetCore.Authorization.Authorize]
@inject IUserSessionService Session
@inject SharepointToolbox.Web.Infrastructure.Persistence.GeneratedReportRepository ReportIndex
@inject Microsoft.Extensions.Options.IOptions<SharepointToolbox.Web.Core.Models.AppConfiguration> Cfg
@inject TranslationSource T
@rendermode InteractiveServer
@using SharepointToolbox.Web.Core.Models
@using SharepointToolbox.Web.Services.Session
<h1 class="page-title">Reports</h1>
<p class="page-subtitle">Generated reports for the selected client.</p>
@if (!Session.HasProfile) { <NoProfilePrompt /> return; }
<div class="card">
<div class="flex-row">
<div class="card-title">@Session.CurrentProfile!.Name <span class="count-badge">@_reports.Count</span></div>
<div class="spacer"></div>
<button class="btn btn-secondary btn-sm" @onclick="Reload">Refresh</button>
</div>
@if (_reports.Count == 0)
{
<div class="alert alert-info">No reports generated yet for this client. Schedules run automatically; an admin can create them under Scheduled Reports.</div>
}
else
{
<div class="data-table-wrap">
<table class="data-table">
<thead>
<tr>
<th>Name</th><th>Type</th><th>Generated (UTC)</th><th>Size</th><th>Status</th><th></th>
</tr>
</thead>
<tbody>
@foreach (var r in _reports)
{
<tr>
<td>@(string.IsNullOrEmpty(r.Name) ? "—" : r.Name)</td>
<td>@r.Type</td>
<td>@r.GeneratedUtc.ToString("yyyy-MM-dd HH:mm")</td>
<td>@(r.Status == ReportRunStatus.Success ? $"{r.SizeBytes / 1024.0:F1} KB" : "—")</td>
<td>
@if (r.Status == ReportRunStatus.Success)
{
<span class="chip chip-green">Success</span>
@if (r.Emailed)
{
<span class="chip chip-blue" style="margin-left:4px">Emailed</span>
}
else if (!string.IsNullOrEmpty(r.EmailError))
{
<span class="chip chip-red" style="margin-left:4px" title="@r.EmailError">Email failed</span>
}
}
else
{
<span class="chip chip-red" title="@r.Error">Failed</span>
}
</td>
<td>
<div class="flex-row" style="gap:6px;justify-content:flex-end">
@if (r.Status == ReportRunStatus.Success)
{
<a class="btn btn-secondary btn-sm" href="/reports/download/@r.Id" target="_blank" rel="noopener">Download</a>
}
<button class="btn btn-danger btn-sm" @onclick="() => DeleteAsync(r)">Delete</button>
</div>
</td>
</tr>
}
</tbody>
</table>
</div>
}
</div>
@code {
private List<GeneratedReport> _reports = new();
protected override async Task OnInitializedAsync() => await Reload();
private async Task Reload()
{
if (!Session.HasProfile) { _reports = new(); return; }
_reports = (await ReportIndex.LoadForProfileAsync(Session.CurrentProfile!.Id)).ToList();
}
private async Task DeleteAsync(GeneratedReport r)
{
// Remove the file (best-effort) then the index entry.
if (r.Status == ReportRunStatus.Success && !string.IsNullOrEmpty(r.FileName))
{
var path = System.IO.Path.Combine(Cfg.Value.ExportsFolder, r.ProfileId, System.IO.Path.GetFileName(r.FileName));
try { if (System.IO.File.Exists(path)) System.IO.File.Delete(path); } catch { /* ignore */ }
}
await ReportIndex.DeleteAsync(r.Id);
await Reload();
}
}