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
+178 -8
View File
@@ -3,13 +3,17 @@
@inject IUserSessionService Session
@inject IUserContextAccessor UserContext
@inject SharepointToolbox.Web.Infrastructure.Persistence.ProfileRepository ProfileRepo
@inject SharepointToolbox.Web.Infrastructure.Auth.IAppOnlyCertStore CertStore
@inject SharepointToolbox.Web.Infrastructure.Auth.IAppOnlyContextFactory AppOnlyFactory
@inject ISessionCredentialStore CredStore
@inject NavigationManager Nav
@inject SharepointToolbox.Web.Services.OAuth.IEntraDeviceCodeFlow DeviceFlow
@inject SharepointToolbox.Web.Services.Auth.IAppRegistrationService AppRegService
@inject SharepointToolbox.Web.Services.Auth.ICertProvisioningService CertProvisioning
@inject Microsoft.Extensions.Options.IOptions<SharepointToolbox.Web.Core.Config.ClientConnectOptions> ConnectOpts
@inject TranslationSource T
@rendermode InteractiveServer
@using Microsoft.AspNetCore.Components.Forms
@using Microsoft.AspNetCore.WebUtilities
@using SharepointToolbox.Web.Core.Models
@using SharepointToolbox.Web.Services.Session
@@ -24,7 +28,7 @@
@foreach (var p in _profiles)
{
<div class="card" style="@(Session.CurrentProfile?.Id == p.Id ? "border-color:#0078d4;border-width:2px" : "")">
<div class="card" style="@(Session.CurrentProfile?.Id == p.Id ? "border-color:var(--accent);border-width:2px" : "")">
<div class="flex-row">
<div>
<div style="font-weight:600;font-size:15px">@p.Name</div>
@@ -62,7 +66,7 @@
@foreach (var p in _profiles)
{
<div class="card" style="@(Session.CurrentProfile?.Id == p.Id ? "border-color:#0078d4;border-width:2px" : "")">
<div class="card" style="@(Session.CurrentProfile?.Id == p.Id ? "border-color:var(--accent);border-width:2px" : "")">
<div class="flex-row">
<div>
<div style="font-weight:600;font-size:15px">@p.Name</div>
@@ -86,7 +90,7 @@
@if (_showForm)
{
<div class="card" style="border-color:#0078d4">
<div class="card" style="border-color:var(--accent)">
<div class="card-title">@(_editing?.Id == null ? T["profiles.form.new"] : T["profiles.form.edit"])</div>
@if (!string.IsNullOrEmpty(_formError))
{
@@ -151,6 +155,64 @@
<LogoUpload Value="_form.ClientLogo" ValueChanged="(LogoData? l) => _form.ClientLogo = l" />
</div>
@* ── App-only credentials for scheduled (unattended) reports ── *@
<div class="form-group" style="border-top:1px solid var(--border);padding-top:14px;margin-top:10px">
<label class="form-label" style="font-size:14px;font-weight:600">Certificate auth (app identity)</label>
@if (_editing is null)
{
<div class="alert alert-info">Save this client first, then re-open it to configure certificate credentials.</div>
}
else
{
<small class="text-muted d-block" style="margin-bottom:8px">
When enabled, this client uses a certificate-based app registration with
<strong>application</strong> permissions (Sites.FullControl.All, admin-consented) for
<strong>both</strong> interactive work and scheduled reports. Technicians never sign in
to SharePoint per profile. The <em>Register app</em> button provisions the certificate
and consent automatically; the fields below are for manual setup.
</small>
<label style="display:block;margin-bottom:8px">
<input type="checkbox" @bind="_form.AppOnlyEnabled" /> Use certificate auth for this client (no per-profile sign-in)
</label>
<label class="form-label">App-only client (application) ID</label>
<input class="form-input" @bind="_form.AppOnlyClientId"
placeholder="GUID of the app registration used for app-only auth" />
<label class="form-label mt-8">Certificate (.pfx)</label>
@if (_certPresent)
{
<div class="flex-row" style="gap:8px;align-items:center">
<span class="chip chip-green">Certificate stored</span>
@if (!string.IsNullOrEmpty(_form.AppOnlyCertThumbprint))
{
<span class="text-muted" style="font-size:12px">@_form.AppOnlyCertThumbprint</span>
}
<button class="btn btn-secondary btn-sm" @onclick="RemoveCertAsync">Remove</button>
</div>
}
else
{
<div class="flex-row" style="gap:8px;align-items:center;flex-wrap:wrap">
<InputFile OnChange="OnCertSelected" accept=".pfx,.p12" />
<input class="form-input" type="password" @bind="_certPassword"
placeholder="PFX password (if any)" style="max-width:220px" />
<button class="btn btn-secondary btn-sm" @onclick="UploadCertAsync" disabled="@(_pfxBytes is null)">
Upload certificate
</button>
</div>
}
<div class="flex-row mt-8" style="gap:8px;align-items:center">
<button class="btn btn-secondary btn-sm" @onclick="TestAppOnlyAsync" disabled="@_appOnlyTesting">
@(_appOnlyTesting ? "Testing…" : "Test connection")
</button>
@if (!string.IsNullOrEmpty(_appOnlyStatus)) { <span class="text-muted" style="font-size:12px">@_appOnlyStatus</span> }
</div>
}
</div>
<div class="flex-row mt-8">
<button class="btn btn-primary" @onclick="SaveProfile">@T["profile.save"]</button>
<button class="btn btn-secondary" @onclick="CancelForm">@T["btn.cancel"]</button>
@@ -171,10 +233,12 @@
private string _regStatus = string.Empty;
private CancellationTokenSource? _regCts;
// Graph delegated scopes the admin must consent to so we can create the app registration.
// Graph delegated scopes the admin must consent to so we can create the app registration,
// attach the certificate, and grant application-permission (app-role) admin consent.
private const string RegistrationScope =
"https://graph.microsoft.com/Application.ReadWrite.All " +
"https://graph.microsoft.com/DelegatedPermissionGrant.ReadWrite.All " +
"https://graph.microsoft.com/AppRoleAssignment.ReadWrite.All " +
"https://graph.microsoft.com/Directory.Read.All " +
"openid offline_access";
@@ -219,9 +283,19 @@
private void EditProfile(TenantProfile p)
{
_editing = p;
_form = new TenantProfile { Id = p.Id, Name = p.Name, TenantUrl = p.TenantUrl, TenantId = p.TenantId, ClientId = p.ClientId, ClientLogo = p.ClientLogo };
_showForm = true;
_form = new TenantProfile
{
Id = p.Id, Name = p.Name, TenantUrl = p.TenantUrl, TenantId = p.TenantId,
ClientId = p.ClientId, ClientLogo = p.ClientLogo,
AppOnlyEnabled = p.AppOnlyEnabled, AppOnlyClientId = p.AppOnlyClientId,
AppOnlyCertThumbprint = p.AppOnlyCertThumbprint
};
_showForm = true;
_formError = _pageError = string.Empty;
_certPresent = CertStore.Exists(p.Id);
_pfxBytes = null;
_certPassword = string.Empty;
_appOnlyStatus = string.Empty;
}
private void CancelForm() { _showForm = false; _editing = null; }
@@ -256,14 +330,32 @@
_regStatus = T["profiles.reg.creating"];
StateHasChanged();
// Generate + store the app-only certificate before creating the registration so its
// public key can be attached as a sign-in credential. Technicians then operate under
// the app identity and never sign in to SharePoint per profile.
var cert = await CertProvisioning.GenerateAndStoreAsync(_form.Id, $"SP Toolbox — {_form.Name}", _regCts.Token);
var clientId = await AppRegService.CreateAsync(
adminAccessToken: adminToken,
tenantName: _form.Name,
redirectUri: ConnectOpts.Value.RedirectUri,
appOnlyCert: cert,
ct: _regCts.Token);
_form.ClientId = clientId;
_regStatus = T["profiles.reg.registered"];
_form.ClientId = clientId;
_form.AppOnlyClientId = clientId;
_form.AppOnlyEnabled = true;
_form.AppOnlyCertThumbprint = cert.Thumbprint;
_certPresent = true;
// Cert key credential + app-role consent take time to propagate through Entra;
// wait it out so the profile is usable immediately instead of 401ing on first use.
_regStatus = T["profiles.reg.propagating"];
StateHasChanged();
var notReady = await AppOnlyFactory.WaitUntilReadyAsync(_form, TimeSpan.FromSeconds(90), _regCts.Token);
_regStatus = notReady is null
? T["profiles.reg.registered"]
: string.Format(T["profiles.reg.notready"], notReady);
}
catch (OperationCanceledException)
{
@@ -317,6 +409,84 @@
{
_profiles.RemoveAll(x => x.Id == p.Id);
await ProfileRepo.SaveAsync(_profiles);
CertStore.Delete(p.Id);
if (Session.CurrentProfile?.Id == p.Id) await Session.ClearSessionAsync();
}
// ── App-only credential handlers ───────────────────────────────────────────
private const long MaxCertBytes = 256 * 1024;
private byte[]? _pfxBytes;
private string _certPassword = string.Empty;
private bool _certPresent;
private bool _appOnlyTesting;
private string _appOnlyStatus = string.Empty;
private async Task OnCertSelected(InputFileChangeEventArgs e)
{
_appOnlyStatus = string.Empty;
var file = e.File;
if (file is null) return;
if (file.Size > MaxCertBytes) { _appOnlyStatus = $"Certificate too large (max {MaxCertBytes / 1024} KB)."; _pfxBytes = null; return; }
try
{
using var ms = new MemoryStream();
await file.OpenReadStream(MaxCertBytes).CopyToAsync(ms);
_pfxBytes = ms.ToArray();
}
catch (Exception ex) { _appOnlyStatus = $"Failed to read certificate: {ex.Message}"; _pfxBytes = null; }
}
private async Task UploadCertAsync()
{
if (_pfxBytes is null || _editing is null) return;
try
{
var thumbprint = await CertStore.SaveAsync(_form.Id, _pfxBytes, string.IsNullOrEmpty(_certPassword) ? null : _certPassword);
_form.AppOnlyCertThumbprint = thumbprint;
_certPresent = true;
_pfxBytes = null;
_certPassword = string.Empty;
await PersistFormAsync();
_appOnlyStatus = "Certificate stored.";
}
catch (Exception ex) { _appOnlyStatus = $"Certificate rejected: {ex.Message}"; }
}
private async Task RemoveCertAsync()
{
if (_editing is null) return;
CertStore.Delete(_form.Id);
_certPresent = false;
_form.AppOnlyCertThumbprint = string.Empty;
await PersistFormAsync();
_appOnlyStatus = "Certificate removed.";
}
private async Task TestAppOnlyAsync()
{
if (_editing is null) return;
_appOnlyTesting = true; _appOnlyStatus = string.Empty;
try
{
// Persist current field edits first so the test uses what the admin sees.
await PersistFormAsync();
var probe = new TenantProfile
{
Id = _form.Id, Name = _form.Name, TenantUrl = _form.TenantUrl, TenantId = _form.TenantId,
AppOnlyEnabled = true, AppOnlyClientId = _form.AppOnlyClientId
};
var error = await AppOnlyFactory.TestConnectionAsync(probe);
_appOnlyStatus = error is null ? "✓ Connected successfully." : $"✗ {error}";
}
catch (Exception ex) { _appOnlyStatus = $"✗ {ex.Message}"; }
finally { _appOnlyTesting = false; }
}
// Upserts the in-progress form into the profile list and saves, without closing the form.
private async Task PersistFormAsync()
{
var idx = _profiles.FindIndex(p => p.Id == _form.Id);
if (idx >= 0) _profiles[idx] = _form; else _profiles.Add(_form);
await ProfileRepo.SaveAsync(_profiles);
}
}