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