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