Files
kawa 6d9c79ad5a Add scheduled reports + app-only cert auth; fix tenant-wide user-access audit
Feature work:
- Certificate (app-only) auth per profile: cert store, context/Graph client
  factories, automated app-registration provisioning (delegated + application
  permissions, admin consent), and a SessionManager seam that resolves the auth
  model per profile.
- Scheduled reports: repositories, hosted service/runner/coordinator, report
  pages, and email delivery (app-only Mail.Send).
- Tenant-wide user-access audit when no site is selected.

Audit fixes:
- Site enumeration: app-only discovery used Graph getAllSites (needs Graph
  Sites.Read.All the cert app lacks) and silently returned empty. Switched to
  the admin-host CSOM TenantSiteEnumerator, matching the scheduler; both auth
  models now share one enumeration path.
- Group expansion: the scan records a SharePoint group as a single principal, so
  user-centric audits found nothing for group-granted access. Resolve group
  membership (shared by audit + scheduler) and attribute it to the target user.
- M365 group claims: the resolver only recognized AAD security groups
  (c:0t.c|). Group-connected/Teams sites grant via the M365 group claim
  (c:0o.c|…|<guid>[_o]); now expanded too, resolving owners for the "_o" claim.
- Provision Directory.Read.All as an application permission so M365/AAD group
  expansion works under the cert identity.

Also: ignore data/appcerts/ (encrypted certificate key material).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 17:55:28 +02:00

93 lines
3.9 KiB
C#

using Microsoft.Online.SharePoint.TenantAdministration;
using Microsoft.SharePoint.Client;
using SharepointToolbox.Web.Core.Models;
namespace SharepointToolbox.Web.Core.Helpers;
/// <summary>
/// Enumerates every site collection in a tenant via the SharePoint tenant-admin
/// endpoint (<c>Tenant.GetSitePropertiesFromSharePointByFilters</c>), paging through
/// all results. Shared by the delegated <c>SiteDiscoveryService</c> and the app-only
/// background report scheduler so both produce the identical, complete site set.
///
/// The caller supplies a <see cref="ClientContext"/> already pointed at the tenant
/// admin host (see <see cref="BuildAdminUrl"/>) and authenticated by whichever model
/// applies. The cold-token 403 retry handles the transient denial a freshly minted
/// delegated token hits against the admin host; it is harmless under app-only auth.
/// </summary>
public static class TenantSiteEnumerator
{
private const int MaxColdTokenAttempts = 4;
private const int BackoffBaseSeconds = 3;
public static async Task<IReadOnlyList<SiteInfo>> EnumerateAsync(ClientContext adminCtx, CancellationToken ct)
{
var tenant = new Tenant(adminCtx);
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(adminCtx, tenant, filter, ct);
foreach (var sp in page)
{
var url = sp.Url ?? string.Empty;
if (string.IsNullOrEmpty(url)) continue;
if (url.Contains("/personal/", StringComparison.OrdinalIgnoreCase)) continue;
var title = string.IsNullOrEmpty(sp.Title) ? url : sp.Title;
results.Add(new SiteInfo(url, title));
}
filter.StartIndex = page.NextStartIndexFromSharePoint;
}
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 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 enumeration (attempt {N}/{Max}); " +
"retrying in {Delay}s. {Err}",
attempt, MaxColdTokenAttempts, delay.TotalSeconds, ex.Message);
await Task.Delay(delay, ct);
}
}
}
/// <summary>https://contoso.sharepoint.com[/sites/Foo] → https://contoso-admin.sharepoint.com</summary>
public 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}";
}
}