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,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>1–31. 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; }
|
||||
}
|
||||
Reference in New Issue
Block a user