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:
+34
@@ -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) =>
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user