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");