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
+3
View File
@@ -4,4 +4,7 @@ public class AppConfiguration
{
public string DataFolder { get; set; } = "/data";
public string ExportsFolder { get; set; } = "/data/exports";
/// <summary>DataProtection-encrypted app-only certificates, one file per client profile.</summary>
public string CertsFolder { get; set; } = "/data/appcerts";
}
+51
View File
@@ -0,0 +1,51 @@
namespace SharepointToolbox.Web.Core.Models;
public enum ReportRunStatus
{
Success,
Failed
}
/// <summary>
/// One produced report file, listed per client. The file itself lives under
/// {ExportsFolder}/{ProfileId}/{FileName}; this record is the index entry that the
/// "Reports" list and the id-based download endpoint resolve against.
/// Persisted to reports-index.json.
/// </summary>
public class GeneratedReport
{
public string Id { get; set; } = Guid.NewGuid().ToString();
public string ProfileId { get; set; } = string.Empty;
/// <summary>The schedule that produced this; null for an ad-hoc/manual run.</summary>
public string? ScheduledReportId { get; set; }
public ReportType Type { get; set; }
/// <summary>Human label (usually copied from the schedule name).</summary>
public string Name { get; set; } = string.Empty;
/// <summary>File name on disk, relative to the profile's exports subfolder.</summary>
public string FileName { get; set; } = string.Empty;
public string Mime { get; set; } = string.Empty;
public long SizeBytes { get; set; }
public DateTime GeneratedUtc { get; set; } = DateTime.UtcNow;
public ReportRunStatus Status { get; set; } = ReportRunStatus.Success;
/// <summary>Populated when <see cref="Status"/> is Failed.</summary>
public string? Error { get; set; }
/// <summary>True when the report was successfully emailed via Graph.</summary>
public bool Emailed { get; set; }
/// <summary>
/// Populated when email delivery was requested but failed. The report itself still
/// succeeded (file is on disk) — only delivery failed.
/// </summary>
public string? EmailError { get; set; }
}
+12
View File
@@ -0,0 +1,12 @@
namespace SharepointToolbox.Web.Core.Models;
/// <summary>The kinds of report that can be generated, scheduled, and exported.</summary>
public enum ReportType
{
Permissions,
Storage,
Duplicates,
UserAccess,
VersionCleanup,
Search
}
+196
View File
@@ -0,0 +1,196 @@
using SharepointToolbox.Web.Services.Export;
namespace SharepointToolbox.Web.Core.Models;
/// <summary>How often a scheduled report recurs.</summary>
public enum ReportFrequency
{
Daily,
Weekly,
Monthly
}
/// <summary>
/// A recurrence rule. The report fires at <see cref="TimeOfDayUtc"/> on the cadence
/// described by <see cref="Frequency"/>. <see cref="DayOfWeek"/> applies to Weekly;
/// <see cref="DayOfMonth"/> applies to Monthly (clamped to the last day of short months).
/// All times are UTC to keep scheduling unambiguous across DST.
/// </summary>
public class RecurrenceRule
{
public ReportFrequency Frequency { get; set; } = ReportFrequency.Weekly;
/// <summary>Time of day to run, UTC, "HH:mm".</summary>
public string TimeOfDayUtc { get; set; } = "06:00";
/// <summary>0 = Sunday … 6 = Saturday. Used when <see cref="Frequency"/> is Weekly.</summary>
public DayOfWeek DayOfWeek { get; set; } = DayOfWeek.Monday;
/// <summary>131. Used when <see cref="Frequency"/> is Monthly.</summary>
public int DayOfMonth { get; set; } = 1;
/// <summary>
/// Computes the next fire time strictly after <paramref name="afterUtc"/>.
/// </summary>
public DateTime ComputeNextRunUtc(DateTime afterUtc)
{
var (hh, mm) = ParseTime(TimeOfDayUtc);
switch (Frequency)
{
case ReportFrequency.Daily:
{
var candidate = new DateTime(afterUtc.Year, afterUtc.Month, afterUtc.Day, hh, mm, 0, DateTimeKind.Utc);
if (candidate <= afterUtc) candidate = candidate.AddDays(1);
return candidate;
}
case ReportFrequency.Weekly:
{
var candidate = new DateTime(afterUtc.Year, afterUtc.Month, afterUtc.Day, hh, mm, 0, DateTimeKind.Utc);
int delta = ((int)DayOfWeek - (int)candidate.DayOfWeek + 7) % 7;
candidate = candidate.AddDays(delta);
if (candidate <= afterUtc) candidate = candidate.AddDays(7);
return candidate;
}
case ReportFrequency.Monthly:
default:
{
var candidate = BuildMonthly(afterUtc.Year, afterUtc.Month, hh, mm);
if (candidate <= afterUtc)
{
var next = afterUtc.AddMonths(1);
candidate = BuildMonthly(next.Year, next.Month, hh, mm);
}
return candidate;
}
}
}
private DateTime BuildMonthly(int year, int month, int hh, int mm)
{
int day = Math.Min(DayOfMonth, DateTime.DaysInMonth(year, month));
return new DateTime(year, month, day, hh, mm, 0, DateTimeKind.Utc);
}
private static (int Hour, int Minute) ParseTime(string s)
{
var parts = (s ?? "06:00").Split(':');
int hh = parts.Length > 0 && int.TryParse(parts[0], out var h) ? Math.Clamp(h, 0, 23) : 6;
int mm = parts.Length > 1 && int.TryParse(parts[1], out var m) ? Math.Clamp(m, 0, 59) : 0;
return (hh, mm);
}
}
/// <summary>
/// Flat, serializable bag of the report-generation options used across all report
/// types. Only the fields relevant to the chosen <see cref="ScheduledReport.Type"/>
/// are honoured; the runner maps them to the concrete option records
/// (<see cref="ScanOptions"/>, <see cref="StorageScanOptions"/>, …).
/// </summary>
public class ScheduledReportOptions
{
// Permissions
public bool IncludeInherited { get; set; }
public bool ScanFolders { get; set; } = true;
// Permissions + Storage
public int FolderDepth { get; set; } = 1;
public bool IncludeSubsites { get; set; }
// Storage
public bool PerLibrary { get; set; } = true;
public bool IncludeHiddenLibraries { get; set; } = true;
public bool IncludePreservationHold { get; set; } = true;
public bool IncludeListAttachments { get; set; } = true;
public bool IncludeRecycleBin { get; set; } = true;
// Duplicates
public string DuplicateMode { get; set; } = "Files";
public bool MatchSize { get; set; } = true;
public bool MatchCreated { get; set; }
public bool MatchModified { get; set; }
public bool MatchSubfolderCount { get; set; }
public bool MatchFileCount { get; set; }
public string? Library { get; set; }
// Version cleanup
public List<string> LibraryTitles { get; set; } = new();
public int KeepLast { get; set; } = 5;
public bool KeepFirst { get; set; }
// Search
public List<string> Extensions { get; set; } = new();
public string? Regex { get; set; }
public int MaxResults { get; set; } = 1000;
// User access audit — the logins/emails to report access for (substring matched).
public List<string> TargetUserLogins { get; set; } = new();
}
/// <summary>
/// Optional email delivery for a generated report, sent through Graph (app-only,
/// <c>Mail.Send</c>). The report file is attached. Body/subject support the
/// placeholders {ReportName}, {ClientName}, {ReportType}, {DateUtc} and {FileName}.
/// </summary>
public class ReportEmailSettings
{
public bool Enabled { get; set; }
/// <summary>
/// Mailbox to send AS (UPN or address). App-only Graph has no signed-in user, so a
/// concrete sender mailbox is required — Graph posts to /users/{From}/sendMail.
/// </summary>
public string From { get; set; } = string.Empty;
public List<string> To { get; set; } = new();
public List<string> Cc { get; set; } = new();
public string Subject { get; set; } = "{ClientName} — {ReportName}";
/// <summary>HTML body. Placeholders are substituted before sending.</summary>
public string Body { get; set; } =
"<p>Hello,</p><p>Please find attached the {ReportType} report \"{ReportName}\" for {ClientName}, generated on {DateUtc} UTC.</p>";
}
/// <summary>
/// A user-defined schedule that generates a report for a single client (profile)
/// on a recurrence. Persisted to schedules.json.
/// </summary>
public class ScheduledReport
{
public string Id { get; set; } = Guid.NewGuid().ToString();
/// <summary><see cref="TenantProfile.Id"/> this schedule belongs to.</summary>
public string ProfileId { get; set; } = string.Empty;
/// <summary>Human label shown in the UI.</summary>
public string Name { get; set; } = string.Empty;
public ReportType Type { get; set; }
public ScheduledReportOptions Options { get; set; } = new();
/// <summary>When true, run against every site in the tenant (site discovery); otherwise use <see cref="SiteUrls"/>.</summary>
public bool AllSites { get; set; } = true;
public List<string> SiteUrls { get; set; } = new();
public ReportMergeMode MergeMode { get; set; } = ReportMergeMode.SingleMerged;
public ReportFormat Format { get; set; } = ReportFormat.Html;
public RecurrenceRule Recurrence { get; set; } = new();
/// <summary>Optional Graph email delivery of the generated report.</summary>
public ReportEmailSettings Email { get; set; } = new();
public bool Enabled { get; set; } = true;
public string CreatedBy { get; set; } = string.Empty;
public DateTime CreatedUtc { get; set; } = DateTime.UtcNow;
public DateTime? LastRunUtc { get; set; }
public DateTime? NextRunUtc { get; set; }
}
+37
View File
@@ -15,4 +15,41 @@ public class TenantProfile
public string ClientId { get; set; } = string.Empty;
public LogoData? ClientLogo { get; set; }
// ── Certificate (app-only) credentials ──────────────────────────────────────
// Opt-in per client by an admin. When enabled, certificate auth drives BOTH the
// interactive session (technicians never sign in to SharePoint per profile) AND
// the background report scheduler — all operations run under the app identity.
// When disabled, the app falls back to the delegated refresh-token sign-in flow.
// SharePoint CSOM app-only requires a certificate (Sites.FullControl.All
// application permission, admin-consented). The certificate itself is NOT stored
// here — it lives DataProtection-encrypted on disk (see AppOnlyCertStore); this
// class only carries the metadata needed to load and display it.
/// <summary>When true, this client uses certificate (app-only) auth for interactive and scheduled work.</summary>
public bool AppOnlyEnabled { get; set; }
/// <summary>Client (application) ID of the app-registration used for certificate auth. May differ from <see cref="ClientId"/>.</summary>
public string AppOnlyClientId { get; set; } = string.Empty;
/// <summary>Thumbprint of the stored certificate — display/verification only; the key material is stored separately.</summary>
public string AppOnlyCertThumbprint { get; set; } = string.Empty;
/// <summary>
/// Clones this profile pointed at a different site/admin URL, preserving every other
/// field (notably the certificate metadata) so the auth model is resolved identically
/// for the derived URL. Use instead of hand-building partial copies.
/// </summary>
public TenantProfile CloneForSite(string siteUrl) => new()
{
Id = Id,
Name = Name,
TenantUrl = siteUrl,
TenantId = TenantId,
ClientId = ClientId,
ClientLogo = ClientLogo,
AppOnlyEnabled = AppOnlyEnabled,
AppOnlyClientId = AppOnlyClientId,
AppOnlyCertThumbprint = AppOnlyCertThumbprint,
};
}
+12
View File
@@ -0,0 +1,12 @@
namespace SharepointToolbox.Web.Core.Models;
/// <summary>
/// Outcome of a user-access audit run. Carries the matched access entries plus per-site
/// scan tallies so the UI can report how many sites were skipped for no access or failed
/// on a non-access error (a tenant-wide scan continues past both).
/// </summary>
public record UserAccessAuditResult(
IReadOnlyList<UserAccessEntry> Entries,
int SitesScanned,
int SitesDenied,
int SitesFailed);