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

183 lines
8.2 KiB
C#

using Microsoft.Graph;
using Microsoft.Graph.Models;
using Microsoft.SharePoint.Client;
using Serilog;
using SharepointToolbox.Web.Core.Helpers;
using SharepointToolbox.Web.Core.Models;
using AppGraphClientFactory = SharepointToolbox.Web.Infrastructure.Auth.GraphClientFactory;
using GraphUser = Microsoft.Graph.Models.User;
using GraphUserCollectionResponse = Microsoft.Graph.Models.UserCollectionResponse;
namespace SharepointToolbox.Web.Services;
public class SharePointGroupResolver : ISharePointGroupResolver
{
private readonly AppGraphClientFactory _graphClientFactory;
public SharePointGroupResolver(AppGraphClientFactory graphClientFactory)
{
_graphClientFactory = graphClientFactory;
}
public async Task<IReadOnlyDictionary<string, IReadOnlyList<ResolvedMember>>> ResolveGroupsAsync(
ClientContext ctx,
TenantProfile profile,
IReadOnlyList<string> groupNames,
CancellationToken ct)
{
var result = new Dictionary<string, IReadOnlyList<ResolvedMember>>(StringComparer.OrdinalIgnoreCase);
if (groupNames.Count == 0) return result;
GraphServiceClient? graphClient = null;
var groupTitles = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
try
{
ctx.Load(ctx.Web.SiteGroups, gs => gs.Include(g => g.Title));
await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(ctx, null, ct);
foreach (var g in ctx.Web.SiteGroups) groupTitles.Add(g.Title);
}
catch (OperationCanceledException) { throw; }
catch (Exception ex) { Log.Warning("Could not enumerate SiteGroups on {Url}: {Error}", ctx.Url, ex.Message); }
foreach (var groupName in groupNames.Distinct(StringComparer.OrdinalIgnoreCase))
{
ct.ThrowIfCancellationRequested();
if (!groupTitles.Contains(groupName))
{
Log.Debug("SP group '{Group}' not present on {Url}; skipping.", groupName, ctx.Url);
result[groupName] = Array.Empty<ResolvedMember>();
continue;
}
try
{
var group = ctx.Web.SiteGroups.GetByName(groupName);
ctx.Load(group.Users, users => users.Include(u => u.Title, u => u.LoginName, u => u.PrincipalType));
await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(ctx, null, ct);
var members = new List<ResolvedMember>();
foreach (var user in group.Users)
{
if (IsAadGroup(user.LoginName))
{
graphClient ??= await _graphClientFactory.CreateClientAsync(profile);
var aadId = ExtractAadGroupId(user.LoginName);
// 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
{
members.Add(new ResolvedMember(user.Title ?? user.LoginName, StripClaims(user.LoginName)));
}
}
result[groupName] = members.DistinctBy(m => m.Login, StringComparer.OrdinalIgnoreCase).ToList();
}
catch (OperationCanceledException) { throw; }
catch (Exception ex)
{
Log.Warning("Could not resolve SP group '{Group}': {Error}", groupName, ex.Message);
result[groupName] = Array.Empty<ResolvedMember>();
}
}
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: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 StripClaims(string login) => login[(login.LastIndexOf('|') + 1)..];
private static async Task<IEnumerable<ResolvedMember>> ResolveAadGroupAsync(
GraphServiceClient graphClient, string aadGroupId, CancellationToken ct)
{
try
{
var response = await graphClient.Groups[aadGroupId].TransitiveMembers.GraphUser.GetAsync(config =>
{
config.QueryParameters.Select = new[] { "displayName", "userPrincipalName" };
config.QueryParameters.Top = 999;
}, ct);
if (response?.Value is null) return Enumerable.Empty<ResolvedMember>();
var members = new List<ResolvedMember>();
var iter = PageIterator<GraphUser, GraphUserCollectionResponse>.CreatePageIterator(
graphClient, response,
user =>
{
if (ct.IsCancellationRequested) return false;
members.Add(new ResolvedMember(
user.DisplayName ?? user.UserPrincipalName ?? "Unknown",
user.UserPrincipalName ?? string.Empty));
return true;
});
await iter.IterateAsync(ct);
return members;
}
catch (OperationCanceledException) { throw; }
catch (Exception ex)
{
Log.Warning("Could not resolve AAD group '{Id}' transitively: {Error}", aadGroupId, ex.Message);
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>();
}
}
}