Files
SharepointToolbox-Web/Core/Models/ScheduledReport.cs
T
kawa 6d9c79ad5a 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>
2026-06-08 17:55:28 +02:00

197 lines
7.2 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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; }
}