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