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:
@@ -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");
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user