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");
+57
View File
@@ -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;
}
}
+10 -3
View File
@@ -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);
}
+25
View File
@@ -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);
}