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
+34
View File
@@ -3,6 +3,7 @@ using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.Authentication.OpenIdConnect;
using Microsoft.AspNetCore.Antiforgery;
using Microsoft.AspNetCore.DataProtection;
using Microsoft.AspNetCore.Identity;
using Microsoft.Extensions.Options;
using Microsoft.IdentityModel.Protocols.OpenIdConnect;
@@ -17,6 +18,7 @@ using SharepointToolbox.Web.Services.Audit;
using SharepointToolbox.Web.Services.Auth;
using SharepointToolbox.Web.Services.Export;
using SharepointToolbox.Web.Services.OAuth;
using SharepointToolbox.Web.Services.Reports;
using SharepointToolbox.Web.Services.Session;
var builder = WebApplication.CreateBuilder(args);
@@ -110,11 +112,14 @@ builder.Services.AddHttpClient("oauth");
builder.Services.Configure<ClientConnectOptions>(builder.Configuration.GetSection("ClientConnect"));
// ── App config ────────────────────────────────────────────────────────────────
var certsFolder = Path.Combine(dataFolder, "appcerts");
builder.Services.Configure<AppConfiguration>(opt =>
{
opt.DataFolder = dataFolder;
opt.ExportsFolder = Path.Combine(dataFolder, "exports");
opt.CertsFolder = certsFolder;
Directory.CreateDirectory(opt.ExportsFolder);
Directory.CreateDirectory(opt.CertsFolder);
});
// ── Persistence (Singleton — files on disk) ───────────────────────────────────
@@ -123,6 +128,13 @@ builder.Services.AddSingleton(new SettingsRepository(Path.Combine(dataFolder, "s
builder.Services.AddSingleton(new TemplateRepository(Path.Combine(dataFolder, "templates")));
builder.Services.AddSingleton(new UserRepository(Path.Combine(dataFolder, "users.json")));
builder.Services.AddSingleton(new AuditRepository(Path.Combine(dataFolder, "audit.jsonl")));
builder.Services.AddSingleton(new ScheduledReportRepository(Path.Combine(dataFolder, "schedules.json")));
builder.Services.AddSingleton(new GeneratedReportRepository(Path.Combine(dataFolder, "reports-index.json")));
// ── App-only (unattended) auth for scheduled reports ──────────────────────────
builder.Services.AddSingleton<IAppOnlyCertStore>(sp =>
new AppOnlyCertStore(certsFolder, sp.GetRequiredService<IDataProtectionProvider>()));
builder.Services.AddSingleton<IAppOnlyContextFactory, AppOnlyContextFactory>();
// ── Auth infrastructure ───────────────────────────────────────────────────────
builder.Services.AddSingleton<IPasswordHasher<AppUser>, PasswordHasher<AppUser>>();
@@ -131,6 +143,7 @@ builder.Services.AddSingleton<IOAuthFlowCache, OAuthFlowCache>();
builder.Services.AddSingleton<IEntraDeviceCodeFlow, EntraDeviceCodeFlow>();
builder.Services.AddHttpClient<ITokenRefreshService, TokenRefreshService>();
builder.Services.AddHttpClient<IAppRegistrationService, AppRegistrationService>();
builder.Services.AddSingleton<ICertProvisioningService, CertProvisioningService>();
builder.Services.AddScoped<GraphClientFactory>();
// ── User session (Scoped = one per Blazor circuit = one per browser tab) ─────
@@ -177,6 +190,12 @@ builder.Services.AddScoped<VersionCleanupHtmlExportService>();
builder.Services.AddScoped<BulkResultCsvExportService>();
builder.Services.AddScoped<WebExportService>();
// ── Scheduled reports (background generation) ─────────────────────────────────
builder.Services.AddSingleton<ScheduledRunCoordinator>();
builder.Services.AddScoped<IReportMailService, ReportMailService>();
builder.Services.AddScoped<IScheduledReportRunner, ScheduledReportRunner>();
builder.Services.AddHostedService<ScheduledReportHostedService>();
var app = builder.Build();
if (!app.Environment.IsDevelopment())
@@ -301,6 +320,21 @@ app.MapGet("/export/download/{fileName}", async (string fileName, IOptions<AppCo
return Results.File(bytes, ct, fileName);
});
// ── Scheduled report download (id-based, scoped to the client's exports subfolder) ──
app.MapGet("/reports/download/{id}", async (string id, GeneratedReportRepository index, IOptions<AppConfiguration> opts, HttpContext ctx) =>
{
if (!ctx.User.Identity?.IsAuthenticated ?? true) return Results.Unauthorized();
var report = await index.GetAsync(id);
if (report is null || report.Status != ReportRunStatus.Success || string.IsNullOrEmpty(report.FileName))
return Results.NotFound();
// ProfileId and FileName are app-generated; GetFileName strips any traversal just in case.
var path = Path.Combine(opts.Value.ExportsFolder, report.ProfileId, Path.GetFileName(report.FileName));
if (!File.Exists(path)) return Results.NotFound();
var bytes = await File.ReadAllBytesAsync(path);
var mime = string.IsNullOrEmpty(report.Mime) ? "application/octet-stream" : report.Mime;
return Results.File(bytes, mime, report.FileName);
});
// ── Audit CSV download ────────────────────────────────────────────────────────
app.MapGet("/audit/export", async (AuditRepository auditRepo, HttpContext ctx) =>
{