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
+107 -49
View File
@@ -24,6 +24,22 @@ public class AppRegistrationService : IAppRegistrationService
"AllSites.FullControl", // CSOM — site permissions, content, admin operations
];
// Graph APPLICATION permissions (app roles) for certificate (app-only) auth.
private static readonly string[] GraphAppRoles =
[
"User.Read.All",
"Group.ReadWrite.All",
"Directory.Read.All", // expand M365/AAD group membership in the user-access audit (SharePointGroupResolver)
"Sites.FullControl.All",
"Mail.Send", // app-only sendMail for emailed scheduled reports
];
// SharePoint APPLICATION permission (app role) for certificate (app-only) CSOM.
private static readonly string[] SpAppRoles =
[
"Sites.FullControl.All",
];
private readonly HttpClient _http;
public AppRegistrationService(HttpClient http) { _http = http; }
@@ -32,103 +48,145 @@ public class AppRegistrationService : IAppRegistrationService
string adminAccessToken,
string tenantName,
string redirectUri,
CertProvisioningResult? appOnlyCert = null,
CancellationToken ct = default)
{
_http.DefaultRequestHeaders.Authorization =
new AuthenticationHeaderValue("Bearer", adminAccessToken);
// 1. Resolve Graph + SharePoint service principals in the target tenant
var (graphSpId, graphPermIds) = await ResolveServicePrincipalAsync(GraphAppId, GraphScopes, ct);
var (spSpId, spPermIds) = await ResolveServicePrincipalAsync(SharePointAppId, SpScopes, ct);
bool wantsAppOnly = appOnlyCert is not null;
// 2. Create app registration
var appBody = new
// 1. Resolve Graph + SharePoint service principals + the permission ids we need
var graph = await ResolveServicePrincipalAsync(GraphAppId, GraphScopes, GraphAppRoles, ct);
var sp = await ResolveServicePrincipalAsync(SharePointAppId, SpScopes, SpAppRoles, ct);
// 2. Create app registration (delegated scopes always; application roles when app-only)
var appBody = new Dictionary<string, object?>
{
displayName = $"SP Toolbox — {tenantName}",
signInAudience = "AzureADMyOrg",
isFallbackPublicClient = true,
["displayName"] = $"SP Toolbox — {tenantName}",
["signInAudience"] = "AzureADMyOrg",
["isFallbackPublicClient"] = true,
// Register the redirect under the PUBLIC client platform so the connect
// flow can redeem the auth code with PKCE only (no client secret). A
// redirect under `web` makes Entra treat the app as confidential and the
// token exchange fails with AADSTS7000218 (secret required).
publicClient = new { redirectUris = new[] { redirectUri } },
requiredResourceAccess = new[]
["publicClient"] = new { redirectUris = new[] { redirectUri } },
["requiredResourceAccess"] = new[]
{
new
{
resourceAppId = GraphAppId,
resourceAccess = graphPermIds.Select(id => new { id, type = "Scope" }).ToArray(),
resourceAccess = ResourceAccess(graph.ScopeIds, wantsAppOnly ? graph.AppRoleIds : []),
},
new
{
resourceAppId = SharePointAppId,
resourceAccess = spPermIds.Select(id => new { id, type = "Scope" }).ToArray(),
resourceAccess = ResourceAccess(sp.ScopeIds, wantsAppOnly ? sp.AppRoleIds : []),
},
},
};
var appJson = await PostGraphAsync("https://graph.microsoft.com/v1.0/applications",
appBody, ct);
// Attach the certificate as a sign-in credential so app-only token requests succeed.
if (wantsAppOnly)
appBody["keyCredentials"] = new[] { BuildKeyCredential(appOnlyCert!, tenantName) };
var appJson = await PostGraphAsync("https://graph.microsoft.com/v1.0/applications", appBody, ct);
var clientId = appJson.GetProperty("appId").GetString()!;
// 3. Create service principal for the new app
var spJson = await PostGraphAsync("https://graph.microsoft.com/v1.0/servicePrincipals",
var spJson = await PostGraphAsync("https://graph.microsoft.com/v1.0/servicePrincipals",
new { appId = clientId }, ct);
var newSpId = spJson.GetProperty("id").GetString()!;
// 4. Grant org-wide admin consent for Graph
await PostGraphAsync("https://graph.microsoft.com/v1.0/oauth2PermissionGrants",
new
{
clientId = newSpId,
consentType = "AllPrincipals",
resourceId = graphSpId,
scope = string.Join(" ", GraphScopes),
}, ct);
// 4. Grant org-wide admin consent for Graph + SharePoint delegated scopes
await GrantDelegatedConsentAsync(newSpId, graph.SpObjectId, GraphScopes, ct);
await GrantDelegatedConsentAsync(newSpId, sp.SpObjectId, SpScopes, ct);
// 5. Grant org-wide admin consent for SharePoint
await PostGraphAsync("https://graph.microsoft.com/v1.0/oauth2PermissionGrants",
new
{
clientId = newSpId,
consentType = "AllPrincipals",
resourceId = spSpId,
scope = string.Join(" ", SpScopes),
}, ct);
// 5. Grant admin consent for application permissions (app roles) when app-only
if (wantsAppOnly)
{
await GrantAppRolesAsync(newSpId, graph.SpObjectId, graph.AppRoleIds, ct);
await GrantAppRolesAsync(newSpId, sp.SpObjectId, sp.AppRoleIds, ct);
}
return clientId;
}
// Returns (servicePrincipalObjectId, [permissionIds matching requested scopes])
private async Task<(string SpObjectId, string[] PermissionIds)> ResolveServicePrincipalAsync(
string appId, string[] scopeNames, CancellationToken ct)
private static object BuildKeyCredential(CertProvisioningResult cert, string tenantName) => new
{
type = "AsymmetricX509Cert",
usage = "Verify",
key = cert.PublicCertBase64,
displayName = $"CN=SP Toolbox — {tenantName}",
startDateTime = cert.NotBefore.UtcDateTime.ToString("o"),
endDateTime = cert.NotAfter.UtcDateTime.ToString("o"),
};
private static object[] ResourceAccess(string[] scopeIds, string[] appRoleIds)
{
var list = new List<object>(scopeIds.Length + appRoleIds.Length);
list.AddRange(scopeIds.Select(id => new { id, type = "Scope" }));
list.AddRange(appRoleIds.Select(id => new { id, type = "Role" }));
return list.ToArray();
}
private async Task GrantDelegatedConsentAsync(string clientSpId, string resourceSpId, string[] scopes, CancellationToken ct)
{
await PostGraphAsync("https://graph.microsoft.com/v1.0/oauth2PermissionGrants",
new
{
clientId = clientSpId,
consentType = "AllPrincipals",
resourceId = resourceSpId,
scope = string.Join(" ", scopes),
}, ct);
}
private async Task GrantAppRolesAsync(string clientSpId, string resourceSpId, string[] appRoleIds, CancellationToken ct)
{
foreach (var appRoleId in appRoleIds)
{
await PostGraphAsync(
$"https://graph.microsoft.com/v1.0/servicePrincipals/{clientSpId}/appRoleAssignments",
new { principalId = clientSpId, resourceId = resourceSpId, appRoleId }, ct);
}
}
// Returns the SP object id plus the ids of the requested delegated scopes and application roles.
private async Task<(string SpObjectId, string[] ScopeIds, string[] AppRoleIds)> ResolveServicePrincipalAsync(
string appId, string[] scopeNames, string[] roleNames, CancellationToken ct)
{
var url = $"https://graph.microsoft.com/v1.0/servicePrincipals" +
$"?$filter=appId eq '{appId}'&$select=id,oauth2PermissionScopes";
$"?$filter=appId eq '{appId}'&$select=id,oauth2PermissionScopes,appRoles";
var resp = await _http.GetAsync(url, ct);
var json = await resp.Content.ReadAsStringAsync(ct);
resp.EnsureSuccessStatusCode();
using var doc = JsonDocument.Parse(json);
var values = doc.RootElement.GetProperty("value");
var sp = values.EnumerateArray().First();
var spId = sp.GetProperty("id").GetString()!;
var allScopes = sp.GetProperty("oauth2PermissionScopes");
using var doc = JsonDocument.Parse(json);
var sp = doc.RootElement.GetProperty("value").EnumerateArray().First();
var spId = sp.GetProperty("id").GetString()!;
var scopeIds = MatchByValue(sp.GetProperty("oauth2PermissionScopes"), scopeNames);
var roleIds = MatchByValue(sp.GetProperty("appRoles"), roleNames);
return (spId, scopeIds, roleIds);
}
private static string[] MatchByValue(JsonElement entries, string[] wantedValues)
{
var ids = new List<string>();
foreach (var scope in allScopes.EnumerateArray())
foreach (var entry in entries.EnumerateArray())
{
var value = scope.GetProperty("value").GetString();
if (scopeNames.Contains(value, StringComparer.OrdinalIgnoreCase))
ids.Add(scope.GetProperty("id").GetString()!);
var value = entry.GetProperty("value").GetString();
if (wantedValues.Contains(value, StringComparer.OrdinalIgnoreCase))
ids.Add(entry.GetProperty("id").GetString()!);
}
return (spId, ids.ToArray());
return ids.ToArray();
}
private async Task<JsonElement> PostGraphAsync(string url, object body, CancellationToken ct)
{
var content = new StringContent(
var content = new StringContent(
JsonSerializer.Serialize(body, new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase }),
Encoding.UTF8,
"application/json");
+57
View File
@@ -0,0 +1,57 @@
using System.Security.Cryptography;
using System.Security.Cryptography.X509Certificates;
using SharepointToolbox.Web.Infrastructure.Auth;
namespace SharepointToolbox.Web.Services.Auth;
/// <summary>
/// Creates a 2048-bit RSA self-signed certificate valid for two years, persists its private
/// key (PFX) through <see cref="IAppOnlyCertStore"/>, and returns the public certificate so
/// the caller can attach it to the Entra app registration as a sign-in credential.
/// </summary>
public class CertProvisioningService : ICertProvisioningService
{
private readonly IAppOnlyCertStore _certStore;
public CertProvisioningService(IAppOnlyCertStore certStore) { _certStore = certStore; }
public async Task<CertProvisioningResult> GenerateAndStoreAsync(
string profileId, string subjectName, CancellationToken ct = default)
{
// X.509 validity is stored at whole-second precision (ASN.1 has no sub-second field).
// Truncate here so the keyCredential start/endDateTime we send to Graph match the
// certificate's embedded validity exactly — otherwise the JSON endDateTime carries
// a fractional second that lands *after* the cert's NotAfter and Graph rejects it
// with KeyCredentialsInvalidEndDate.
var notBefore = TruncateToSecond(DateTimeOffset.UtcNow.AddMinutes(-5));
var notAfter = notBefore.AddYears(2);
using var rsa = RSA.Create(2048);
var req = new CertificateRequest(
$"CN={Sanitize(subjectName)}", rsa, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
req.CertificateExtensions.Add(new X509BasicConstraintsExtension(false, false, 0, false));
req.CertificateExtensions.Add(new X509KeyUsageExtension(X509KeyUsageFlags.DigitalSignature, false));
using var cert = req.CreateSelfSigned(notBefore, notAfter);
// Transient password only protects the in-memory PFX handoff to the store, which
// re-exports it password-less and encrypts at rest with Data Protection.
var transientPwd = Convert.ToBase64String(RandomNumberGenerator.GetBytes(24));
var pfxBytes = cert.Export(X509ContentType.Pkcs12, transientPwd);
var thumbprint = await _certStore.SaveAsync(profileId, pfxBytes, transientPwd, ct);
var publicBase64 = Convert.ToBase64String(cert.Export(X509ContentType.Cert));
return new CertProvisioningResult(thumbprint, publicBase64, notBefore, notAfter);
}
private static DateTimeOffset TruncateToSecond(DateTimeOffset value) =>
new(value.Ticks - (value.Ticks % TimeSpan.TicksPerSecond), value.Offset);
// CN cannot contain characters that break the X.500 distinguished name.
private static string Sanitize(string name)
{
var cleaned = name.Replace(",", " ").Replace("=", " ").Replace("\"", " ").Trim();
return string.IsNullOrEmpty(cleaned) ? "SP Toolbox" : cleaned;
}
}
+10 -3
View File
@@ -4,13 +4,20 @@ public interface IAppRegistrationService
{
/// <summary>
/// Creates an Entra ID app registration in the target tenant using a delegated admin token
/// (requires Application.ReadWrite.All + DelegatedPermissionGrant.ReadWrite.All scope).
/// Grants org-wide admin consent for SharePoint + Graph delegated permissions.
/// Returns the new app's client ID (appId).
/// (requires Application.ReadWrite.All + DelegatedPermissionGrant.ReadWrite.All +
/// AppRoleAssignment.ReadWrite.All scope). Grants org-wide admin consent for SharePoint + Graph
/// delegated permissions (fallback sign-in flow).
///
/// When <paramref name="appOnlyCert"/> is supplied, the registration is also provisioned for
/// certificate (app-only) auth: the public certificate is attached as a sign-in credential,
/// SharePoint + Graph <em>application</em> permissions are requested, and admin consent for
/// those app roles is granted. This lets technicians operate under the app identity without an
/// interactive sign-in. Returns the new app's client ID (appId).
/// </summary>
Task<string> CreateAsync(
string adminAccessToken,
string tenantName,
string redirectUri,
CertProvisioningResult? appOnlyCert = null,
CancellationToken ct = default);
}
+25
View File
@@ -0,0 +1,25 @@
namespace SharepointToolbox.Web.Services.Auth;
/// <summary>
/// Public material of a freshly generated app-only certificate. The private key is already
/// stored (encrypted) in the cert store; these fields are what the app registration needs
/// to trust the certificate as a sign-in credential.
/// </summary>
/// <param name="Thumbprint">SHA-1 thumbprint of the generated certificate.</param>
/// <param name="PublicCertBase64">Base64 of the DER-encoded public certificate (Graph keyCredential.key).</param>
/// <param name="NotBefore">Validity start (UTC).</param>
/// <param name="NotAfter">Validity end (UTC).</param>
public record CertProvisioningResult(
string Thumbprint,
string PublicCertBase64,
DateTimeOffset NotBefore,
DateTimeOffset NotAfter);
/// <summary>
/// Generates a self-signed certificate for a client profile, stores the private key in the
/// app-only cert store, and returns the public material to register against the Entra app.
/// </summary>
public interface ICertProvisioningService
{
Task<CertProvisioningResult> GenerateAndStoreAsync(string profileId, string subjectName, CancellationToken ct = default);
}
+1 -1
View File
@@ -4,7 +4,7 @@ namespace SharepointToolbox.Web.Services;
public interface IUserAccessAuditService
{
Task<IReadOnlyList<UserAccessEntry>> AuditUsersAsync(
Task<UserAccessAuditResult> AuditUsersAsync(
ISessionManager sessionManager,
TenantProfile currentProfile,
IReadOnlyList<string> targetUserLogins,
+21
View File
@@ -0,0 +1,21 @@
using SharepointToolbox.Web.Core.Models;
namespace SharepointToolbox.Web.Services.Reports;
/// <summary>Sends a generated report as a Graph email (app-only, Mail.Send).</summary>
public interface IReportMailService
{
/// <summary>
/// Sends <paramref name="bytes"/> as an attachment to the recipients in
/// <paramref name="settings"/>, sending AS <see cref="ReportEmailSettings.From"/>.
/// Subject/body placeholders are resolved from the schedule and client.
/// </summary>
Task SendAsync(
TenantProfile profile,
ScheduledReport schedule,
ReportEmailSettings settings,
string fileName,
string mime,
byte[] bytes,
CancellationToken ct = default);
}
@@ -0,0 +1,14 @@
using SharepointToolbox.Web.Core.Models;
namespace SharepointToolbox.Web.Services.Reports;
public interface IScheduledReportRunner
{
/// <summary>
/// Generates one report for the given schedule using app-only auth, writes the
/// file under the client's exports subfolder, records it in the report index, and
/// audit-logs the run. Never throws for report-level failures — a failed run is
/// captured as a <see cref="GeneratedReport"/> with <see cref="ReportRunStatus.Failed"/>.
/// </summary>
Task<GeneratedReport> RunAsync(ScheduledReport schedule, CancellationToken ct = default);
}
+90
View File
@@ -0,0 +1,90 @@
using Microsoft.Graph.Models;
using Microsoft.Graph.Users.Item.SendMail;
using SharepointToolbox.Web.Core.Models;
using SharepointToolbox.Web.Infrastructure.Auth;
namespace SharepointToolbox.Web.Services.Reports;
/// <summary>
/// Delivers a generated report by email through Graph. Uses the client's app-only
/// (certificate) Graph client, which has no signed-in user, so it posts to
/// <c>/users/{From}/sendMail</c> — the configured sender mailbox must exist in the
/// tenant and the app registration must hold the <c>Mail.Send</c> application role.
/// </summary>
public class ReportMailService : IReportMailService
{
// Graph caps a single sendMail request at ~4 MB total; larger files need an upload
// session we don't implement. Reject early with a clear message instead of a 413.
private const long MaxAttachmentBytes = 3 * 1024 * 1024;
private readonly IAppOnlyContextFactory _appOnly;
public ReportMailService(IAppOnlyContextFactory appOnly) { _appOnly = appOnly; }
public async Task SendAsync(
TenantProfile profile,
ScheduledReport schedule,
ReportEmailSettings settings,
string fileName,
string mime,
byte[] bytes,
CancellationToken ct = default)
{
if (string.IsNullOrWhiteSpace(settings.From))
throw new InvalidOperationException("No sender mailbox (From) configured for report email.");
var to = CleanAddresses(settings.To);
var cc = CleanAddresses(settings.Cc);
if (to.Count == 0 && cc.Count == 0)
throw new InvalidOperationException("Report email has no To or Cc recipients.");
var message = new Message
{
Subject = Substitute(settings.Subject, profile, schedule, fileName),
Body = new ItemBody
{
ContentType = BodyType.Html,
Content = Substitute(settings.Body, profile, schedule, fileName)
},
ToRecipients = to.Select(Recipient).ToList(),
CcRecipients = cc.Select(Recipient).ToList(),
};
if (bytes.LongLength > MaxAttachmentBytes)
throw new InvalidOperationException(
$"Report '{fileName}' is {bytes.LongLength / 1024.0 / 1024.0:F1} MB — too large to email " +
$"(Graph sendMail limit ~{MaxAttachmentBytes / 1024 / 1024} MB).");
message.Attachments = new List<Attachment>
{
new FileAttachment
{
OdataType = "#microsoft.graph.fileAttachment",
Name = fileName,
ContentType = mime,
ContentBytes = bytes,
}
};
var graph = await _appOnly.CreateGraphClientAsync(profile, ct);
await graph.Users[settings.From].SendMail.PostAsync(
new SendMailPostRequestBody { Message = message, SaveToSentItems = false }, cancellationToken: ct);
}
private static List<string> CleanAddresses(IEnumerable<string> raw) =>
raw.Select(a => a?.Trim() ?? string.Empty)
.Where(a => a.Length > 0)
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToList();
private static Recipient Recipient(string address) =>
new() { EmailAddress = new EmailAddress { Address = address } };
private static string Substitute(string template, TenantProfile profile, ScheduledReport schedule, string fileName) =>
(template ?? string.Empty)
.Replace("{ReportName}", schedule.Name)
.Replace("{ClientName}", profile.Name)
.Replace("{ReportType}", schedule.Type.ToString())
.Replace("{FileName}", fileName)
.Replace("{DateUtc}", DateTime.UtcNow.ToString("yyyy-MM-dd HH:mm"));
}
@@ -0,0 +1,130 @@
using Microsoft.Extensions.Logging;
using SharepointToolbox.Web.Core.Models;
using SharepointToolbox.Web.Infrastructure.Persistence;
namespace SharepointToolbox.Web.Services.Reports;
/// <summary>
/// Background scheduler. Every <see cref="TickInterval"/> it loads the schedule
/// definitions, runs any whose <see cref="ScheduledReport.NextRunUtc"/> is due, and
/// advances their next-run stamp. Each run executes in its own DI scope (report
/// services are scoped). Due schedules run sequentially within a tick to bound the
/// load a single tenant sees; a long run simply delays the next due check.
/// </summary>
public class ScheduledReportHostedService : BackgroundService
{
private static readonly TimeSpan TickInterval = TimeSpan.FromMinutes(1);
private readonly IServiceScopeFactory _scopeFactory;
private readonly ScheduledReportRepository _repo;
private readonly ScheduledRunCoordinator _coordinator;
private readonly ILogger<ScheduledReportHostedService> _log;
public ScheduledReportHostedService(
IServiceScopeFactory scopeFactory,
ScheduledReportRepository repo,
ScheduledRunCoordinator coordinator,
ILogger<ScheduledReportHostedService> log)
{
_scopeFactory = scopeFactory;
_repo = repo;
_coordinator = coordinator;
_log = log;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
_log.LogInformation("Scheduled report service started (tick {Interval}).", TickInterval);
using var timer = new PeriodicTimer(TickInterval);
// Tick once at startup, then on every interval.
do
{
try { await TickAsync(stoppingToken); }
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) { break; }
catch (Exception ex) { _log.LogError(ex, "Scheduled report tick failed."); }
}
while (await WaitAsync(timer, stoppingToken));
_log.LogInformation("Scheduled report service stopping.");
}
private static async Task<bool> WaitAsync(PeriodicTimer timer, CancellationToken ct)
{
try { return await timer.WaitForNextTickAsync(ct); }
catch (OperationCanceledException) { return false; }
}
private async Task TickAsync(CancellationToken ct)
{
// Global pause: an admin has suspended all cadence-triggered runs. In-flight
// runs are unaffected (those are stopped individually); due schedules simply
// wait — NextRun is not advanced, so they fire once resumed.
if (_coordinator.IsPaused) return;
var now = DateTime.UtcNow;
var schedules = await _repo.LoadAsync();
foreach (var schedule in schedules)
{
ct.ThrowIfCancellationRequested();
if (!schedule.Enabled) continue;
// First time we see an enabled schedule with no next-run: arm it, don't run.
if (schedule.NextRunUtc is null)
{
schedule.NextRunUtc = schedule.Recurrence.ComputeNextRunUtc(now);
await _repo.UpsertAsync(schedule);
continue;
}
if (schedule.NextRunUtc > now) continue;
await RunOneAsync(schedule, now, ct);
}
}
private async Task RunOneAsync(ScheduledReport schedule, DateTime now, CancellationToken ct)
{
// Register the run so the UI can stop it mid-flight. The returned token trips on
// either app shutdown (ct) or an admin Stop. Null = a run is already in progress
// (e.g. a long previous run or a "Run now"); skip without advancing so it retries.
var token = _coordinator.TryBegin(schedule.Id, ct);
if (token is null)
{
_log.LogWarning("Schedule '{Name}' ({Id}) still running; skipping this tick.", schedule.Name, schedule.Id);
return;
}
try
{
using var scope = _scopeFactory.CreateScope();
var runner = scope.ServiceProvider.GetRequiredService<IScheduledReportRunner>();
await runner.RunAsync(schedule, token.Value);
}
catch (OperationCanceledException) when (ct.IsCancellationRequested)
{
throw; // app shutdown — bubble up to stop the service loop
}
catch (OperationCanceledException)
{
// Stopped via the coordinator (admin Stop / Stop all), not shutdown. Not a failure.
_log.LogInformation("Schedule '{Name}' ({Id}) was stopped.", schedule.Name, schedule.Id);
}
catch (Exception ex)
{
// RunAsync already captures report-level failures; this guards anything
// thrown outside it so one bad schedule can't stop the others advancing.
_log.LogError(ex, "Schedule '{Name}' ({Id}) failed to run.", schedule.Name, schedule.Id);
}
finally
{
_coordinator.Complete(schedule.Id);
// Advance regardless of outcome so a persistently failing (or stopped)
// schedule doesn't hot-loop every tick.
schedule.LastRunUtc = now;
schedule.NextRunUtc = schedule.Recurrence.ComputeNextRunUtc(now);
await _repo.UpsertAsync(schedule);
}
}
}
+297
View File
@@ -0,0 +1,297 @@
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Microsoft.SharePoint.Client;
using SharepointToolbox.Web.Core.Helpers;
using SharepointToolbox.Web.Core.Models;
using SharepointToolbox.Web.Infrastructure.Auth;
using SharepointToolbox.Web.Infrastructure.Persistence;
using SharepointToolbox.Web.Services.Export;
using AppConfiguration = SharepointToolbox.Web.Core.Models.AppConfiguration;
namespace SharepointToolbox.Web.Services.Reports;
/// <summary>
/// Drives one scheduled report end-to-end under app-only auth: resolve the client
/// profile, discover/select sites, scan each site with the matching report service,
/// merge the per-site results into a single artifact, write it to the client's
/// exports subfolder, and index + audit the run.
///
/// Report services take a plain <see cref="ClientContext"/>, so they are reused
/// verbatim; the only difference from the interactive pages is that the context is
/// produced by <see cref="IAppOnlyContextFactory"/> instead of the delegated session.
/// </summary>
public class ScheduledReportRunner : IScheduledReportRunner
{
private readonly ProfileRepository _profiles;
private readonly SettingsRepository _settings;
private readonly GeneratedReportRepository _index;
private readonly AuditRepository _audit;
private readonly IAppOnlyContextFactory _appOnly;
private readonly IReportMailService _mail;
private readonly AppConfiguration _cfg;
private readonly ILogger<ScheduledReportRunner> _log;
private readonly IPermissionsService _perm;
private readonly ISharePointGroupResolver _groupResolver;
private readonly IStorageService _storage;
private readonly IDuplicatesService _dup;
private readonly ISearchService _search;
private readonly IVersionCleanupService _version;
private readonly CsvExportService _permCsv;
private readonly HtmlExportService _permHtml;
private readonly StorageCsvExportService _storageCsv;
private readonly StorageHtmlExportService _storageHtml;
private readonly DuplicatesCsvExportService _dupCsv;
private readonly DuplicatesHtmlExportService _dupHtml;
private readonly SearchCsvExportService _searchCsv;
private readonly SearchHtmlExportService _searchHtml;
private readonly UserAccessCsvExportService _uaCsv;
private readonly UserAccessHtmlExportService _uaHtml;
private readonly VersionCleanupHtmlExportService _versionHtml;
public ScheduledReportRunner(
ProfileRepository profiles, SettingsRepository settings,
GeneratedReportRepository index, AuditRepository audit,
IAppOnlyContextFactory appOnly, IReportMailService mail, IOptions<AppConfiguration> cfg,
ILogger<ScheduledReportRunner> log,
IPermissionsService perm, ISharePointGroupResolver groupResolver,
IStorageService storage, IDuplicatesService dup,
ISearchService search, IVersionCleanupService version,
CsvExportService permCsv, HtmlExportService permHtml,
StorageCsvExportService storageCsv, StorageHtmlExportService storageHtml,
DuplicatesCsvExportService dupCsv, DuplicatesHtmlExportService dupHtml,
SearchCsvExportService searchCsv, SearchHtmlExportService searchHtml,
UserAccessCsvExportService uaCsv, UserAccessHtmlExportService uaHtml,
VersionCleanupHtmlExportService versionHtml)
{
_profiles = profiles; _settings = settings; _index = index; _audit = audit;
_appOnly = appOnly; _mail = mail; _cfg = cfg.Value; _log = log;
_perm = perm; _groupResolver = groupResolver; _storage = storage; _dup = dup; _search = search; _version = version;
_permCsv = permCsv; _permHtml = permHtml;
_storageCsv = storageCsv; _storageHtml = storageHtml;
_dupCsv = dupCsv; _dupHtml = dupHtml;
_searchCsv = searchCsv; _searchHtml = searchHtml;
_uaCsv = uaCsv; _uaHtml = uaHtml; _versionHtml = versionHtml;
}
public async Task<GeneratedReport> RunAsync(ScheduledReport schedule, CancellationToken ct = default)
{
var profile = (await _profiles.LoadAsync()).FirstOrDefault(p => p.Id == schedule.ProfileId);
if (profile is null)
return await FailAsync(schedule, profileName: "(unknown)", $"Client profile '{schedule.ProfileId}' not found.");
if (!profile.AppOnlyEnabled)
return await FailAsync(schedule, profile.Name, $"App-only reports are not enabled for client '{profile.Name}'.");
try
{
var sites = await ResolveSitesAsync(profile, schedule, ct);
if (sites.Count == 0)
return await FailAsync(schedule, profile.Name, "No sites resolved for this schedule.");
var settings = await _settings.LoadAsync();
var branding = new ReportBranding(settings.MspLogo, profile.ClientLogo);
var ts = DateTime.UtcNow.ToString("yyyyMMdd_HHmmss");
var output = await GenerateAsync(profile, schedule, sites, branding, ts, ct);
var dir = Path.Combine(_cfg.ExportsFolder, profile.Id);
Directory.CreateDirectory(dir);
var path = Path.Combine(dir, output.FileName);
await System.IO.File.WriteAllBytesAsync(path, output.Bytes, ct);
var report = new GeneratedReport
{
ProfileId = profile.Id,
ScheduledReportId = schedule.Id,
Type = schedule.Type,
Name = schedule.Name,
FileName = output.FileName,
Mime = output.Mime,
SizeBytes = output.Bytes.LongLength,
Status = ReportRunStatus.Success
};
// Optional email delivery. A delivery failure does NOT fail the report —
// the file is already on disk and indexed; we record the error on the entry.
string mailNote = "";
if (schedule.Email.Enabled)
{
try
{
await _mail.SendAsync(profile, schedule, schedule.Email,
output.FileName, output.Mime, output.Bytes, ct);
report.Emailed = true;
mailNote = $", emailed to {schedule.Email.To.Count + schedule.Email.Cc.Count} recipient(s)";
}
catch (OperationCanceledException) { throw; }
catch (Exception mex)
{
report.EmailError = mex.Message;
mailNote = $", email FAILED: {mex.Message}";
_log.LogError(mex, "Emailing report '{Name}' for '{Client}' failed", schedule.Name, profile.Name);
}
}
await _index.AddAsync(report);
await AuditAsync(profile.Name, schedule, ReportRunStatus.Success,
$"{schedule.Type} report '{output.FileName}' ({sites.Count} site(s), {output.Bytes.LongLength / 1024.0:F1} KB){mailNote}");
_log.LogInformation("Scheduled report '{Name}' for '{Client}' produced {File}",
schedule.Name, profile.Name, output.FileName);
return report;
}
catch (OperationCanceledException)
{
throw;
}
catch (Exception ex)
{
_log.LogError(ex, "Scheduled report '{Name}' for '{Client}' failed", schedule.Name, profile.Name);
return await FailAsync(schedule, profile.Name, ex.Message);
}
}
private async Task<IReadOnlyList<SiteInfo>> ResolveSitesAsync(
TenantProfile profile, ScheduledReport schedule, CancellationToken ct)
{
if (!schedule.AllSites)
return schedule.SiteUrls
.Where(u => !string.IsNullOrWhiteSpace(u))
.Select(u => new SiteInfo(u, ReportSplitHelper.DeriveSiteLabel(u)))
.ToList();
using var adminCtx = await _appOnly.CreateContextAsync(
profile, TenantSiteEnumerator.BuildAdminUrl(profile.TenantUrl), ct);
return await TenantSiteEnumerator.EnumerateAsync(adminCtx, ct);
}
private async Task<MergeOutput> GenerateAsync(
TenantProfile profile, ScheduledReport schedule, IReadOnlyList<SiteInfo> sites,
ReportBranding branding, string ts, CancellationToken ct)
{
var o = schedule.Options;
var progress = new Progress<OperationProgress>();
var mode = schedule.MergeMode;
var fmt = schedule.Format;
// Runs the same per-site scan loop for every report type. ClientContext is
// app-only and disposed per site.
async Task<List<(string Label, IReadOnlyList<T> Results)>> ScanSites<T>(
Func<ClientContext, SiteInfo, Task<IReadOnlyList<T>>> scan)
{
var bySite = new List<(string, IReadOnlyList<T>)>();
foreach (var site in sites)
{
ct.ThrowIfCancellationRequested();
using var ctx = await _appOnly.CreateContextAsync(profile, site.Url, ct);
bySite.Add((site.Title, await scan(ctx, site)));
}
return bySite;
}
switch (schedule.Type)
{
case ReportType.Permissions:
{
var opts = new ScanOptions(o.IncludeInherited, o.ScanFolders, o.FolderDepth, o.IncludeSubsites);
var bySite = await ScanSites<PermissionEntry>((ctx, _) => _perm.ScanSiteAsync(ctx, opts, progress, ct));
return ReportMergeHelper.Build(bySite, mode, "permissions", ts, fmt,
fmt == ReportFormat.Csv ? rs => _permCsv.BuildCsv(rs) : rs => _permHtml.BuildHtml(rs, branding));
}
case ReportType.Storage:
{
var opts = new StorageScanOptions(o.PerLibrary, o.IncludeSubsites, o.FolderDepth,
o.IncludeHiddenLibraries, o.IncludePreservationHold, o.IncludeListAttachments, o.IncludeRecycleBin);
var bySite = await ScanSites<StorageNode>((ctx, _) => _storage.CollectStorageAsync(ctx, opts, progress, ct));
return ReportMergeHelper.Build(bySite, mode, "storage", ts, fmt,
fmt == ReportFormat.Csv ? rs => _storageCsv.BuildCsv(rs) : rs => _storageHtml.BuildHtml(rs, branding));
}
case ReportType.Duplicates:
{
var opts = new DuplicateScanOptions(o.DuplicateMode, o.MatchSize, o.MatchCreated, o.MatchModified,
o.MatchSubfolderCount, o.MatchFileCount, o.IncludeSubsites, o.Library);
var bySite = await ScanSites<DuplicateGroup>((ctx, _) => _dup.ScanDuplicatesAsync(ctx, opts, progress, ct));
return ReportMergeHelper.Build(bySite, mode, "duplicates", ts, fmt,
fmt == ReportFormat.Csv ? rs => _dupCsv.BuildCsv(rs) : rs => _dupHtml.BuildHtml(rs, branding));
}
case ReportType.Search:
{
var bySite = await ScanSites<SearchResult>((ctx, site) =>
{
var opts = new SearchOptions(o.Extensions.ToArray(), o.Regex,
null, null, null, null, null, null, o.Library, o.MaxResults, site.Url);
return _search.SearchFilesAsync(ctx, opts, progress, ct);
});
return ReportMergeHelper.Build(bySite, mode, "search", ts, fmt,
fmt == ReportFormat.Csv ? rs => _searchCsv.BuildCsv(rs) : rs => _searchHtml.BuildHtml(rs, branding));
}
case ReportType.UserAccess:
{
var opts = new ScanOptions(o.IncludeInherited, o.ScanFolders, o.FolderDepth, o.IncludeSubsites);
var targets = o.TargetUserLogins
.Select(l => l.Trim().ToLowerInvariant())
.Where(l => l.Length > 0).ToHashSet();
var bySite = await ScanSites<UserAccessEntry>(async (ctx, site) =>
{
var permEntries = await _perm.ScanSiteAsync(ctx, opts, progress, ct);
// Expand SharePoint group membership so group-granted access is attributed to
// the target user (otherwise the scan only sees the group principal, not the user).
var groupMembers = await UserAccessAuditService.ResolveGroupMembersAsync(
_groupResolver, ctx, profile, permEntries, ct);
return UserAccessAuditService.TransformEntries(permEntries, targets, site, groupMembers).ToList();
});
return ReportMergeHelper.Build(bySite, mode, "user_audit", ts, fmt,
fmt == ReportFormat.Csv
? rs => _uaCsv.BuildCsv(rs.FirstOrDefault()?.UserDisplayName ?? "Users", rs.FirstOrDefault()?.UserLogin ?? "", rs)
: rs => _uaHtml.BuildHtml(rs, mergePermissions: false, branding: branding));
}
case ReportType.VersionCleanup:
{
// Destructive: this DELETES old file versions. No CSV exporter exists, so the
// output is always the HTML summary of what was removed.
var opts = new VersionCleanupOptions(o.LibraryTitles, o.KeepLast, o.KeepFirst);
var bySite = await ScanSites<VersionCleanupResult>((ctx, _) => _version.DeleteOldVersionsAsync(ctx, opts, progress, ct));
return ReportMergeHelper.Build(bySite, mode, "versions", ts, ReportFormat.Html,
rs => _versionHtml.BuildHtml(rs, branding));
}
default:
throw new NotSupportedException($"Report type {schedule.Type} is not supported.");
}
}
private async Task<GeneratedReport> FailAsync(ScheduledReport schedule, string profileName, string error)
{
var report = new GeneratedReport
{
ProfileId = schedule.ProfileId,
ScheduledReportId = schedule.Id,
Type = schedule.Type,
Name = schedule.Name,
Status = ReportRunStatus.Failed,
Error = error
};
await _index.AddAsync(report);
await AuditAsync(profileName, schedule, ReportRunStatus.Failed, error);
return report;
}
private Task AuditAsync(string profileName, ScheduledReport schedule, ReportRunStatus status, string details)
=> _audit.AppendAsync(new AuditEntry
{
Action = "ScheduledReport",
ClientName = profileName,
Sites = new List<string>(),
Details = $"{status}: {schedule.Name} — {details}",
UserEmail = "system",
UserDisplay = "Scheduler",
UserRole = UserRole.Admin
});
}
@@ -0,0 +1,78 @@
using System.Collections.Concurrent;
namespace SharepointToolbox.Web.Services.Reports;
/// <summary>
/// Process-wide coordinator for scheduled-report execution. Covers two operator needs:
/// • <b>Cancel an in-flight run</b> — every active run registers a linked
/// <see cref="CancellationTokenSource"/> keyed by schedule id, so the UI (or a
/// global stop) can abort a report that is currently executing, whether it was
/// started by the scheduler or by "Run now".
/// • <b>Pause future runs</b> — a global flag the background scheduler honours,
/// letting an admin suspend all cadence-triggered runs at once without toggling
/// each schedule's <c>Enabled</c> flag.
///
/// In-memory and singleton. The pause flag does NOT survive a process restart (a
/// restart resumes the scheduler); per-schedule <c>Enabled</c> flags persist and are
/// the durable way to keep a schedule off.
/// </summary>
public sealed class ScheduledRunCoordinator
{
private readonly ConcurrentDictionary<string, CancellationTokenSource> _active = new();
private volatile bool _paused;
/// <summary>True while the scheduler is globally paused (no schedules fire).</summary>
public bool IsPaused => _paused;
public void Pause() => _paused = true;
public void Resume() => _paused = false;
/// <summary>True while a run is registered for this schedule id.</summary>
public bool IsRunning(string scheduleId) => _active.ContainsKey(scheduleId);
/// <summary>Snapshot of schedule ids with a run in progress.</summary>
public IReadOnlyCollection<string> RunningIds => _active.Keys.ToArray();
/// <summary>
/// Registers a run for <paramref name="scheduleId"/> and returns a token that trips
/// when either the caller's <paramref name="linked"/> token (e.g. app shutdown) or a
/// <see cref="Cancel"/>/<see cref="CancelAll"/> call fires. Returns <c>null</c> if a
/// run is already registered for this schedule — callers must skip to avoid overlap.
/// Always pair a non-null return with <see cref="Complete"/> in a <c>finally</c>.
/// </summary>
public CancellationToken? TryBegin(string scheduleId, CancellationToken linked)
{
var cts = CancellationTokenSource.CreateLinkedTokenSource(linked);
if (!_active.TryAdd(scheduleId, cts)) { cts.Dispose(); return null; }
return cts.Token;
}
/// <summary>Deregisters and disposes the run for this schedule id.</summary>
public void Complete(string scheduleId)
{
if (_active.TryRemove(scheduleId, out var cts)) cts.Dispose();
}
/// <summary>Signals cancellation to the run for this schedule id. Returns false if none.</summary>
public bool Cancel(string scheduleId)
{
if (_active.TryGetValue(scheduleId, out var cts))
{
try { cts.Cancel(); return true; }
catch (ObjectDisposedException) { return false; } // completed between lookup and cancel
}
return false;
}
/// <summary>Signals cancellation to every run in progress. Returns the count signalled.</summary>
public int CancelAll()
{
int n = 0;
foreach (var cts in _active.Values)
{
try { cts.Cancel(); n++; }
catch (ObjectDisposedException) { /* completed concurrently */ }
}
return n;
}
}
+60 -3
View File
@@ -63,7 +63,11 @@ public class SharePointGroupResolver : ISharePointGroupResolver
{
graphClient ??= await _graphClientFactory.CreateClientAsync(profile);
var aadId = ExtractAadGroupId(user.LoginName);
var leafUsers = await ResolveAadGroupAsync(graphClient, aadId, ct);
// M365 (group-connected) sites add the group's OWNERS claim ("…_o") to the
// site Owners SP group; resolve owners for those, transitive members otherwise.
var leafUsers = IsM365GroupOwnersClaim(user.LoginName)
? await ResolveAadGroupOwnersAsync(graphClient, aadId, ct)
: await ResolveAadGroupAsync(graphClient, aadId, ct);
members.AddRange(leafUsers);
}
else
@@ -83,10 +87,27 @@ public class SharePointGroupResolver : ISharePointGroupResolver
return result;
}
// Group principals that must be expanded via Graph:
// c:0t.c|tenant|<guid> → AAD security group
// c:0o.c|federateddirectoryclaimprovider|<guid> → M365 group members (group-connected/Teams sites)
// c:0o.c|federateddirectoryclaimprovider|<guid>_o → M365 group owners
// The M365 cases are how modern group-connected sites grant access; without expanding them a
// user who is "just a member of the site" never appears in a user-centric audit.
internal static bool IsAadGroup(string login) =>
login.StartsWith("c:0t.c|", StringComparison.OrdinalIgnoreCase);
login.StartsWith("c:0t.c|", StringComparison.OrdinalIgnoreCase) ||
login.StartsWith("c:0o.c|", StringComparison.OrdinalIgnoreCase);
internal static bool IsM365GroupOwnersClaim(string login) =>
login.StartsWith("c:0o.c|", StringComparison.OrdinalIgnoreCase) &&
login.EndsWith("_o", StringComparison.OrdinalIgnoreCase);
// Last claim segment is the group GUID; M365 owners claims append "_o" — strip it.
internal static string ExtractAadGroupId(string login)
{
var id = login[(login.LastIndexOf('|') + 1)..];
return id.EndsWith("_o", StringComparison.OrdinalIgnoreCase) ? id[..^2] : id;
}
internal static string ExtractAadGroupId(string login) => login[(login.LastIndexOf('|') + 1)..];
internal static string StripClaims(string login) => login[(login.LastIndexOf('|') + 1)..];
private static async Task<IEnumerable<ResolvedMember>> ResolveAadGroupAsync(
@@ -122,4 +143,40 @@ public class SharePointGroupResolver : ISharePointGroupResolver
return Enumerable.Empty<ResolvedMember>();
}
}
// M365 group owners (the "…_o" claim). Owners are a direct, non-nested collection, so no
// transitive expansion is needed — owners cannot themselves be groups.
private static async Task<IEnumerable<ResolvedMember>> ResolveAadGroupOwnersAsync(
GraphServiceClient graphClient, string aadGroupId, CancellationToken ct)
{
try
{
var response = await graphClient.Groups[aadGroupId].Owners.GraphUser.GetAsync(config =>
{
config.QueryParameters.Select = new[] { "displayName", "userPrincipalName" };
config.QueryParameters.Top = 999;
}, ct);
if (response?.Value is null) return Enumerable.Empty<ResolvedMember>();
var owners = new List<ResolvedMember>();
var iter = PageIterator<GraphUser, GraphUserCollectionResponse>.CreatePageIterator(
graphClient, response,
user =>
{
if (ct.IsCancellationRequested) return false;
owners.Add(new ResolvedMember(
user.DisplayName ?? user.UserPrincipalName ?? "Unknown",
user.UserPrincipalName ?? string.Empty));
return true;
});
await iter.IterateAsync(ct);
return owners;
}
catch (OperationCanceledException) { throw; }
catch (Exception ex)
{
Log.Warning("Could not resolve AAD group '{Id}' owners: {Error}", aadGroupId, ex.Message);
return Enumerable.Empty<ResolvedMember>();
}
}
}
+30 -107
View File
@@ -1,31 +1,38 @@
using Microsoft.Online.SharePoint.TenantAdministration;
using Microsoft.SharePoint.Client;
using SharepointToolbox.Web.Core.Helpers;
using SharepointToolbox.Web.Core.Models;
using SharepointToolbox.Web.Infrastructure.Auth;
using SharepointToolbox.Web.Services.Session;
namespace SharepointToolbox.Web.Services;
/// <summary>
/// Delegated CSOM implementation of <see cref="ISiteDiscoveryService"/>.
/// Enumerates every site collection in a tenant via the SharePoint tenant-admin endpoint
/// (<c>Tenant.GetSitePropertiesFromSharePointByFilters</c>), paging through all results.
/// The auth model only changes how the admin-host context is built:
///
/// Enumerates every site collection via the SharePoint tenant admin endpoint
/// (<c>Tenant.GetSitePropertiesFromSharePointByFilters</c>), paging through all
/// results. Requires the signed-in user to be a SharePoint administrator.
/// • Certificate (app-only) profiles build the admin context through the cert factory — the
/// same path the background report scheduler uses (<see cref="Services.Reports"/>), which
/// relies only on the SharePoint <c>Sites.FullControl.All</c> application permission the cert
/// app already holds. (The earlier Graph <c>/sites/getAllSites</c> path was dropped: it needs
/// a separate Graph <c>Sites.Read.All</c> grant the cert app is not provisioned with, so it
/// returned empty/403 and tenant-wide audits silently fell back to the root site alone.)
/// • Delegated profiles build the admin context through the session manager; this requires the
/// signed-in user to be a SharePoint administrator.
///
/// The Graph <c>/sites?search=*</c> endpoint was deliberately abandoned: it ranks
/// by relevance and is capped server-side, so it silently dropped sites and
/// returned varying counts run-to-run. <c>/sites/getAllSites</c> is app-only and
/// 403s on a delegated user token. The tenant admin enumeration is the only path
/// that returns the complete, stable set under the app's delegated auth model.
/// The Graph <c>/sites?search=*</c> endpoint was deliberately abandoned for both: it ranks by
/// relevance and is capped server-side, silently dropping sites and returning varying counts.
/// </summary>
public class SiteDiscoveryService : ISiteDiscoveryService
{
private readonly ISessionManager _sessionManager;
private readonly IAppOnlyContextFactory _appOnly;
public SiteDiscoveryService(ISessionManager sessionManager)
public SiteDiscoveryService(
ISessionManager sessionManager,
IAppOnlyContextFactory appOnly)
{
_sessionManager = sessionManager;
_appOnly = appOnly;
}
public async Task<IReadOnlyList<SiteInfo>> SearchSitesAsync(
@@ -35,103 +42,19 @@ public class SiteDiscoveryService : ISiteDiscoveryService
{
ArgumentException.ThrowIfNullOrEmpty(profile.TenantUrl);
// Site enumeration only exists on the tenant admin endpoint.
var adminProfile = new TenantProfile
var adminUrl = TenantSiteEnumerator.BuildAdminUrl(profile.TenantUrl);
// App-only profiles: build the admin-host context through the cert factory (matches the
// scheduler), enumerating under the SharePoint app permission the cert already grants.
if (_appOnly.IsConfigured(profile))
{
Id = profile.Id,
Name = profile.Name,
TenantUrl = BuildAdminUrl(profile.TenantUrl),
TenantId = profile.TenantId,
ClientId = profile.ClientId,
ClientLogo = profile.ClientLogo,
};
var ctx = await _sessionManager.GetOrCreateContextAsync(adminProfile, ct);
var tenant = new Tenant(ctx);
var filter = new SPOSitePropertiesEnumerableFilter
{
IncludeDetail = false,
IncludePersonalSite = PersonalSiteFilter.Exclude,
StartIndex = null,
Template = null,
};
var results = new List<SiteInfo>();
SPOSitePropertiesEnumerable page;
do
{
ct.ThrowIfCancellationRequested();
page = await FetchPageWithColdTokenRetryAsync(ctx, tenant, filter, ct);
foreach (var sp in page)
{
var url = sp.Url ?? string.Empty;
if (string.IsNullOrEmpty(url)) continue;
// Belt-and-braces: PersonalSiteFilter.Exclude already drops OneDrive.
if (url.Contains("/personal/", StringComparison.OrdinalIgnoreCase)) continue;
var title = string.IsNullOrEmpty(sp.Title) ? url : sp.Title;
results.Add(new SiteInfo(url, title));
}
// NextStartIndexFromSharePoint is empty/null once the last page is returned.
filter.StartIndex = page.NextStartIndexFromSharePoint;
using var adminCtx = await _appOnly.CreateContextAsync(profile, adminUrl, ct);
return await TenantSiteEnumerator.EnumerateAsync(adminCtx, ct);
}
while (!string.IsNullOrEmpty(filter.StartIndex));
return results
.GroupBy(s => s.Url, StringComparer.OrdinalIgnoreCase)
.Select(g => g.First())
.OrderBy(s => s.Title, StringComparer.OrdinalIgnoreCase)
.ToList();
}
private const int MaxColdTokenAttempts = 4;
private const int BackoffBaseSeconds = 3;
// The tenant admin endpoint transiently 403s on a cold delegated token (the same
// behaviour the elevation coordinator handles): the first call against the admin
// host can be denied while the token warms, then clears within seconds. Retry the
// admin query on access-denied with backoff. A genuine lack of SharePoint tenant
// administrator rights keeps failing and surfaces the enriched 403 after retries —
// elevation cannot self-grant tenant-admin, so there is nothing to auto-correct.
//
// The request (GetSiteProperties + Load) MUST be re-issued inside the loop: a failed
// CSOM ExecuteQuery clears the context's pending-request queue, so retrying the
// execute alone would run an empty batch, leave the page uninitialized, and throw
// "The collection has not been initialized" on iteration.
private static async Task<SPOSitePropertiesEnumerable> FetchPageWithColdTokenRetryAsync(
ClientContext ctx, Tenant tenant, SPOSitePropertiesEnumerableFilter filter, CancellationToken ct)
{
for (int attempt = 1; ; attempt++)
{
try
{
var page = tenant.GetSitePropertiesFromSharePointByFilters(filter);
ctx.Load(page);
await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(ctx, null, ct);
return page;
}
catch (SharePointAccessDeniedException ex) when (attempt < MaxColdTokenAttempts)
{
var delay = TimeSpan.FromSeconds(BackoffBaseSeconds * attempt);
Serilog.Log.Warning(
"Tenant admin endpoint denied during site discovery (attempt {N}/{Max}); " +
"retrying in {Delay}s. {Err}",
attempt, MaxColdTokenAttempts, delay.TotalSeconds, ex.Message);
await Task.Delay(delay, ct);
}
}
}
// https://contoso.sharepoint.com[/sites/Foo] → https://contoso-admin.sharepoint.com
private static string BuildAdminUrl(string tenantUrl)
{
if (!Uri.TryCreate(tenantUrl, UriKind.Absolute, out var uri))
return tenantUrl;
var adminHost = uri.Host.Replace(".sharepoint.com", "-admin.sharepoint.com",
StringComparison.OrdinalIgnoreCase);
return $"{uri.Scheme}://{adminHost}";
// Delegated profiles: enumeration only exists on the tenant admin endpoint.
var adminProfile = profile.CloneForSite(adminUrl);
var ctx = await _sessionManager.GetOrCreateContextAsync(adminProfile, ct);
return await TenantSiteEnumerator.EnumerateAsync(ctx, ct);
}
}
+145 -38
View File
@@ -7,19 +7,27 @@ public class UserAccessAuditService : IUserAccessAuditService
{
private readonly IPermissionsService _permissionsService;
private readonly IElevationCoordinator _elevation;
private readonly ISharePointGroupResolver _groupResolver;
private static readonly HashSet<string> HighPrivilegeLevels = new(StringComparer.OrdinalIgnoreCase)
{
"Full Control", "Site Collection Administrator"
};
public UserAccessAuditService(IPermissionsService permissionsService, IElevationCoordinator elevation)
private static readonly IReadOnlyDictionary<string, IReadOnlyList<ResolvedMember>> NoGroupMembers =
new Dictionary<string, IReadOnlyList<ResolvedMember>>();
public UserAccessAuditService(
IPermissionsService permissionsService,
IElevationCoordinator elevation,
ISharePointGroupResolver groupResolver)
{
_permissionsService = permissionsService;
_elevation = elevation;
_groupResolver = groupResolver;
}
public async Task<IReadOnlyList<UserAccessEntry>> AuditUsersAsync(
public async Task<UserAccessAuditResult> AuditUsersAsync(
ISessionManager sessionManager,
TenantProfile currentProfile,
IReadOnlyList<string> targetUserLogins,
@@ -32,10 +40,16 @@ public class UserAccessAuditService : IUserAccessAuditService
.Select(l => l.Trim().ToLowerInvariant())
.Where(l => l.Length > 0).ToHashSet();
if (targets.Count == 0) return Array.Empty<UserAccessEntry>();
if (targets.Count == 0) return new UserAccessAuditResult(Array.Empty<UserAccessEntry>(), 0, 0, 0);
var allEntries = new List<UserAccessEntry>();
// Per-site resilience: when auditing many sites (e.g. the whole tenant), one bad site
// must not abort the run. Access-denied is the expected "tech has no access here" case
// and is skipped quietly; any other error is skipped but counted as a failure so it can
// be surfaced. Cancellation always propagates.
int deniedSites = 0, failedSites = 0;
for (int i = 0; i < sites.Count; i++)
{
ct.ThrowIfCancellationRequested();
@@ -43,64 +57,157 @@ public class UserAccessAuditService : IUserAccessAuditService
progress.Report(new OperationProgress(i, sites.Count,
$"Scanning site {i + 1}/{sites.Count}: {site.Title}..."));
var profile = new TenantProfile
{
TenantUrl = site.Url,
TenantId = currentProfile.TenantId,
ClientId = currentProfile.ClientId,
Name = site.Title
};
var profile = currentProfile.CloneForSite(site.Url);
profile.Name = site.Title;
// Auto-elevates site-collection admin ownership and retries when a scan is denied,
// if the AutoTakeOwnership setting is enabled (otherwise the access-denied propagates).
var permEntries = await _elevation.RunAsync(async c =>
try
{
var ctx = await sessionManager.GetOrCreateContextAsync(profile, c);
return await _permissionsService.ScanSiteAsync(ctx, options, progress, c);
}, ct);
// Auto-elevates site-collection admin ownership and retries when a scan is denied,
// if the AutoTakeOwnership setting is enabled (otherwise the access-denied propagates).
var permEntries = await _elevation.RunAsync(async c =>
{
var ctx = await sessionManager.GetOrCreateContextAsync(profile, c);
return await _permissionsService.ScanSiteAsync(ctx, options, progress, c);
}, ct);
allEntries.AddRange(TransformEntries(permEntries, targets, site));
// Most users get access through SharePoint group membership, not direct grants.
// The scan records the group as a single principal, so the group's members must be
// expanded (including nested AAD groups, via the resolver) for a user-centric audit
// to attribute that access to the target — without it the audit finds nothing.
var siteCtx = await sessionManager.GetOrCreateContextAsync(profile, ct);
var groupMembers = await ResolveGroupMembersAsync(_groupResolver, siteCtx, profile, permEntries, ct);
allEntries.AddRange(TransformEntries(permEntries, targets, site, groupMembers));
}
catch (OperationCanceledException)
{
throw;
}
catch (SharePointAccessDeniedException)
{
// No access to this site (and elevation could not / was not allowed to fix it).
// Expected when scanning the whole tenant under a delegated identity — skip.
deniedSites++;
}
catch (Exception)
{
// Transient/throttling/malformed-site error — skip and keep going.
failedSites++;
}
}
progress.Report(new OperationProgress(sites.Count, sites.Count,
$"Audit complete: {allEntries.Count} access entries found."));
return allEntries;
var summary = $"Audit complete: {allEntries.Count} access entries found.";
if (deniedSites > 0) summary += $" {deniedSites} site(s) skipped (no access).";
if (failedSites > 0) summary += $" {failedSites} site(s) failed.";
progress.Report(new OperationProgress(sites.Count, sites.Count, summary));
return new UserAccessAuditResult(allEntries, sites.Count, deniedSites, failedSites);
}
private static IEnumerable<UserAccessEntry> TransformEntries(
IReadOnlyList<PermissionEntry> permEntries, HashSet<string> targets, SiteInfo site)
/// <summary>
/// Resolves every SharePoint group referenced by <paramref name="permEntries"/> to its
/// member set (expanding nested AAD groups via Graph), so group-granted access can be
/// attributed to the target user. Returns an empty map if there are no group principals
/// or resolution fails (the audit then falls back to direct grants only rather than abort).
/// Shared by the interactive audit and the background report scheduler.
/// </summary>
public static async Task<IReadOnlyDictionary<string, IReadOnlyList<ResolvedMember>>> ResolveGroupMembersAsync(
ISharePointGroupResolver resolver,
Microsoft.SharePoint.Client.ClientContext ctx,
TenantProfile profile,
IReadOnlyList<PermissionEntry> permEntries,
CancellationToken ct)
{
var groupNames = permEntries
.Where(e => string.Equals(e.PrincipalType, "SharePointGroup", StringComparison.OrdinalIgnoreCase))
.Select(e => e.Users) // group title; matches GrantedThrough "SharePoint Group: {title}"
.Where(n => !string.IsNullOrWhiteSpace(n))
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToList();
if (groupNames.Count == 0) return NoGroupMembers;
try
{
return await resolver.ResolveGroupsAsync(ctx, profile, groupNames, ct);
}
catch (OperationCanceledException) { throw; }
catch (Exception ex)
{
Serilog.Log.Warning("User-access audit: SP group expansion failed on {Url}: {Error}", ctx.Url, ex.Message);
return NoGroupMembers;
}
}
/// <summary>
/// Projects raw permission entries for one site into per-user access entries, keeping only
/// rows touching one of <paramref name="targets"/> (substring match on login). Direct user
/// grants match the principal's own login; SharePoint-group grants match against the group's
/// expanded membership in <paramref name="groupMembers"/> (see <see cref="ResolveGroupMembersAsync"/>),
/// so access held through a group is attributed to its members. Exposed for the background
/// report scheduler, which reuses this projection under app-only auth.
/// </summary>
internal static IEnumerable<UserAccessEntry> TransformEntries(
IReadOnlyList<PermissionEntry> permEntries, HashSet<string> targets, SiteInfo site,
IReadOnlyDictionary<string, IReadOnlyList<ResolvedMember>>? groupMembers = null)
{
foreach (var entry in permEntries)
{
var logins = entry.UserLogins.Split(';', StringSplitOptions.RemoveEmptyEntries);
var names = entry.Users.Split(';', StringSplitOptions.RemoveEmptyEntries);
var permLevels = entry.PermissionLevels.Split(';', StringSplitOptions.RemoveEmptyEntries);
UserAccessEntry Build(string displayName, string login, AccessType accessType, string level) =>
new(displayName, StripClaimsPrefix(login),
site.Url, site.Title,
entry.ObjectType, entry.Title, entry.Url,
level, accessType, entry.GrantedThrough,
HighPrivilegeLevels.Contains(level),
PermissionEntryHelper.IsExternalUser(login),
entry.TargetUrl, entry.TargetLabel, entry.SharingLinkType);
bool isGroup = string.Equals(entry.PrincipalType, "SharePointGroup", StringComparison.OrdinalIgnoreCase);
if (isGroup)
{
// Group principal: match the target against the group's expanded members. Without a
// resolved map (or an unknown group) there is no user to attribute access to — skip.
if (groupMembers is null
|| string.IsNullOrEmpty(entry.Users)
|| !groupMembers.TryGetValue(entry.Users, out var members))
continue;
var accessType = entry.HasUniquePermissions ? AccessType.Group : AccessType.Inherited;
foreach (var m in members)
{
var loginLower = m.Login.ToLowerInvariant();
if (string.IsNullOrEmpty(loginLower)) continue;
if (!targets.Any(t => loginLower.Contains(t) || t.Contains(loginLower))) continue;
foreach (var level in permLevels)
{
var trimmed = level.Trim();
if (string.IsNullOrEmpty(trimmed)) continue;
yield return Build(m.DisplayName, m.Login, accessType, trimmed);
}
}
continue;
}
// Direct / external user grant (also the joined site-collection-admins entry).
var logins = entry.UserLogins.Split(';', StringSplitOptions.RemoveEmptyEntries);
var names = entry.Users.Split(';', StringSplitOptions.RemoveEmptyEntries);
for (int u = 0; u < logins.Length; u++)
{
var login = logins[u].Trim();
var loginLower = login.ToLowerInvariant();
var displayName = u < names.Length ? names[u].Trim() : login;
bool isTarget = targets.Any(t => loginLower.Contains(t) || t.Contains(loginLower));
if (!isTarget) continue;
var accessType = !entry.HasUniquePermissions ? AccessType.Inherited
: entry.GrantedThrough.StartsWith("SharePoint Group:", StringComparison.OrdinalIgnoreCase)
? AccessType.Group : AccessType.Direct;
if (!targets.Any(t => loginLower.Contains(t) || t.Contains(loginLower))) continue;
var accessType = entry.HasUniquePermissions ? AccessType.Direct : AccessType.Inherited;
foreach (var level in permLevels)
{
var trimmed = level.Trim();
if (string.IsNullOrEmpty(trimmed)) continue;
yield return new UserAccessEntry(
displayName, StripClaimsPrefix(login),
site.Url, site.Title,
entry.ObjectType, entry.Title, entry.Url,
trimmed, accessType, entry.GrantedThrough,
HighPrivilegeLevels.Contains(trimmed),
PermissionEntryHelper.IsExternalUser(login),
entry.TargetUrl, entry.TargetLabel, entry.SharingLinkType);
yield return Build(displayName, login, accessType, trimmed);
}
}
}