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