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