Merge scheduled reports + cert auth + user-access audit fixes
This commit is contained in:
@@ -64,3 +64,4 @@ data/logs/
|
|||||||
data/exports/
|
data/exports/
|
||||||
data/templates/
|
data/templates/
|
||||||
data/audit.jsonl
|
data/audit.jsonl
|
||||||
|
data/appcerts/
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
@inject IUserContextAccessor UserContext
|
@inject IUserContextAccessor UserContext
|
||||||
@inject ISessionCredentialStore CredStore
|
@inject ISessionCredentialStore CredStore
|
||||||
@inject ISessionManager SessionManager
|
@inject ISessionManager SessionManager
|
||||||
|
@inject SharepointToolbox.Web.Infrastructure.Auth.IAppOnlyContextFactory AppOnly
|
||||||
@inject NavigationManager Nav
|
@inject NavigationManager Nav
|
||||||
@inject IJSRuntime JS
|
@inject IJSRuntime JS
|
||||||
@inject SharepointToolbox.Web.Services.OAuth.IOAuthFlowCache OAuthCache
|
@inject SharepointToolbox.Web.Services.OAuth.IOAuthFlowCache OAuthCache
|
||||||
@@ -44,8 +45,11 @@
|
|||||||
{
|
{
|
||||||
<div style="font-size:10px;color:var(--text-muted);margin-top:4px">
|
<div style="font-size:10px;color:var(--text-muted);margin-top:4px">
|
||||||
SP: @_credUsername
|
SP: @_credUsername
|
||||||
<button class="btn btn-secondary btn-sm" style="padding:2px 6px;font-size:10px;margin-left:4px"
|
@if (!CurrentProfileUsesCert)
|
||||||
@onclick="ReconnectAsync">@T["nav.reconnect"]</button>
|
{
|
||||||
|
<button class="btn btn-secondary btn-sm" style="padding:2px 6px;font-size:10px;margin-left:4px"
|
||||||
|
@onclick="ReconnectAsync">@T["nav.reconnect"]</button>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
@@ -149,7 +153,9 @@
|
|||||||
new("/folder-structure", "📁", "tab.folderStructure", "nav.section.bulk", "profile"),
|
new("/folder-structure", "📁", "tab.folderStructure", "nav.section.bulk", "profile"),
|
||||||
new("/user-audit", "👤", "tab.userAccessAudit", "nav.section.audit", "profile"),
|
new("/user-audit", "👤", "tab.userAccessAudit", "nav.section.audit", "profile"),
|
||||||
new("/user-directory", "📖", "nav.userDirectory", "nav.section.audit", "profile"),
|
new("/user-directory", "📖", "nav.userDirectory", "nav.section.audit", "profile"),
|
||||||
|
new("/reports", "📑", "nav.reports", "nav.section.audit", "profile"),
|
||||||
new("/templates", "📐", "tab.templates", "nav.section.config", "profile"),
|
new("/templates", "📐", "tab.templates", "nav.section.config", "profile"),
|
||||||
|
new("/scheduled-reports", "⏰", "nav.scheduledReports", "nav.section.admin", "admin"),
|
||||||
new("/profiles", "⚙️", "nav.clientProfiles", "nav.section.admin", "admin"),
|
new("/profiles", "⚙️", "nav.clientProfiles", "nav.section.admin", "admin"),
|
||||||
new("/admin/users", "👥", "nav.userManagement", "nav.section.admin", "admin"),
|
new("/admin/users", "👥", "nav.userManagement", "nav.section.admin", "admin"),
|
||||||
new("/admin/audit", "📋", "nav.auditLogs", "nav.section.admin", "admin"),
|
new("/admin/audit", "📋", "nav.auditLogs", "nav.section.admin", "admin"),
|
||||||
@@ -219,11 +225,16 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// If profile selected but no credentials → show modal
|
// If profile selected but no credentials → show modal (cert profiles never prompt)
|
||||||
if (Session.HasProfile && !_hasCredentials && _credModal is not null)
|
if (Session.HasProfile && !_hasCredentials && !CurrentProfileUsesCert && _credModal is not null)
|
||||||
await _credModal.ShowAsync();
|
await _credModal.ShowAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// True when the selected profile authenticates app-only via a stored certificate —
|
||||||
|
// technicians operate under the app identity and are never prompted to sign in.
|
||||||
|
private bool CurrentProfileUsesCert =>
|
||||||
|
Session.CurrentProfile is { } p && AppOnly.IsConfigured(p);
|
||||||
|
|
||||||
private async Task HandleOAuthCallbackAsync()
|
private async Task HandleOAuthCallbackAsync()
|
||||||
{
|
{
|
||||||
var uri = new Uri(Nav.Uri);
|
var uri = new Uri(Nav.Uri);
|
||||||
@@ -256,6 +267,16 @@
|
|||||||
|
|
||||||
private async Task RefreshCredentialState()
|
private async Task RefreshCredentialState()
|
||||||
{
|
{
|
||||||
|
// Certificate-configured profiles need no session tokens — mark as connected
|
||||||
|
// under the app identity and skip the delegated token bookkeeping entirely.
|
||||||
|
if (CurrentProfileUsesCert)
|
||||||
|
{
|
||||||
|
_hasCredentials = true;
|
||||||
|
_credUsername = $"{Session.CurrentProfile!.Name} ({T["nav.appIdentity"]})";
|
||||||
|
await InvokeAsync(StateHasChanged);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
var tokens = await CredStore.GetAsync();
|
var tokens = await CredStore.GetAsync();
|
||||||
|
|
||||||
// Session tokens are tenant-bound (refresh token issued for the profile's TenantId/ClientId).
|
// Session tokens are tenant-bound (refresh token issued for the profile's TenantId/ClientId).
|
||||||
|
|||||||
@@ -61,7 +61,7 @@
|
|||||||
@foreach (var g in _results.Take(100))
|
@foreach (var g in _results.Take(100))
|
||||||
{
|
{
|
||||||
<div style="margin-bottom:8px;border:1px solid var(--border);border-radius:4px;overflow:hidden">
|
<div style="margin-bottom:8px;border:1px solid var(--border);border-radius:4px;overflow:hidden">
|
||||||
<div style="background:#f0f0f0;padding:6px 12px;font-weight:600;font-size:13px">
|
<div style="background:var(--th-bg);padding:6px 12px;font-weight:600;font-size:13px">
|
||||||
@g.Name <span class="chip chip-blue">@g.Items.Count @T["report.text.copies"]</span>
|
@g.Name <span class="chip chip-blue">@g.Items.Count @T["report.text.copies"]</span>
|
||||||
</div>
|
</div>
|
||||||
@foreach (var item in g.Items)
|
@foreach (var item in g.Items)
|
||||||
|
|||||||
@@ -3,13 +3,17 @@
|
|||||||
@inject IUserSessionService Session
|
@inject IUserSessionService Session
|
||||||
@inject IUserContextAccessor UserContext
|
@inject IUserContextAccessor UserContext
|
||||||
@inject SharepointToolbox.Web.Infrastructure.Persistence.ProfileRepository ProfileRepo
|
@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 ISessionCredentialStore CredStore
|
||||||
@inject NavigationManager Nav
|
@inject NavigationManager Nav
|
||||||
@inject SharepointToolbox.Web.Services.OAuth.IEntraDeviceCodeFlow DeviceFlow
|
@inject SharepointToolbox.Web.Services.OAuth.IEntraDeviceCodeFlow DeviceFlow
|
||||||
@inject SharepointToolbox.Web.Services.Auth.IAppRegistrationService AppRegService
|
@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 Microsoft.Extensions.Options.IOptions<SharepointToolbox.Web.Core.Config.ClientConnectOptions> ConnectOpts
|
||||||
@inject TranslationSource T
|
@inject TranslationSource T
|
||||||
@rendermode InteractiveServer
|
@rendermode InteractiveServer
|
||||||
|
@using Microsoft.AspNetCore.Components.Forms
|
||||||
@using Microsoft.AspNetCore.WebUtilities
|
@using Microsoft.AspNetCore.WebUtilities
|
||||||
@using SharepointToolbox.Web.Core.Models
|
@using SharepointToolbox.Web.Core.Models
|
||||||
@using SharepointToolbox.Web.Services.Session
|
@using SharepointToolbox.Web.Services.Session
|
||||||
@@ -24,7 +28,7 @@
|
|||||||
|
|
||||||
@foreach (var p in _profiles)
|
@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 class="flex-row">
|
||||||
<div>
|
<div>
|
||||||
<div style="font-weight:600;font-size:15px">@p.Name</div>
|
<div style="font-weight:600;font-size:15px">@p.Name</div>
|
||||||
@@ -62,7 +66,7 @@
|
|||||||
|
|
||||||
@foreach (var p in _profiles)
|
@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 class="flex-row">
|
||||||
<div>
|
<div>
|
||||||
<div style="font-weight:600;font-size:15px">@p.Name</div>
|
<div style="font-weight:600;font-size:15px">@p.Name</div>
|
||||||
@@ -86,7 +90,7 @@
|
|||||||
|
|
||||||
@if (_showForm)
|
@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>
|
<div class="card-title">@(_editing?.Id == null ? T["profiles.form.new"] : T["profiles.form.edit"])</div>
|
||||||
@if (!string.IsNullOrEmpty(_formError))
|
@if (!string.IsNullOrEmpty(_formError))
|
||||||
{
|
{
|
||||||
@@ -151,6 +155,64 @@
|
|||||||
<LogoUpload Value="_form.ClientLogo" ValueChanged="(LogoData? l) => _form.ClientLogo = l" />
|
<LogoUpload Value="_form.ClientLogo" ValueChanged="(LogoData? l) => _form.ClientLogo = l" />
|
||||||
</div>
|
</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">
|
<div class="flex-row mt-8">
|
||||||
<button class="btn btn-primary" @onclick="SaveProfile">@T["profile.save"]</button>
|
<button class="btn btn-primary" @onclick="SaveProfile">@T["profile.save"]</button>
|
||||||
<button class="btn btn-secondary" @onclick="CancelForm">@T["btn.cancel"]</button>
|
<button class="btn btn-secondary" @onclick="CancelForm">@T["btn.cancel"]</button>
|
||||||
@@ -171,10 +233,12 @@
|
|||||||
private string _regStatus = string.Empty;
|
private string _regStatus = string.Empty;
|
||||||
private CancellationTokenSource? _regCts;
|
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 =
|
private const string RegistrationScope =
|
||||||
"https://graph.microsoft.com/Application.ReadWrite.All " +
|
"https://graph.microsoft.com/Application.ReadWrite.All " +
|
||||||
"https://graph.microsoft.com/DelegatedPermissionGrant.ReadWrite.All " +
|
"https://graph.microsoft.com/DelegatedPermissionGrant.ReadWrite.All " +
|
||||||
|
"https://graph.microsoft.com/AppRoleAssignment.ReadWrite.All " +
|
||||||
"https://graph.microsoft.com/Directory.Read.All " +
|
"https://graph.microsoft.com/Directory.Read.All " +
|
||||||
"openid offline_access";
|
"openid offline_access";
|
||||||
|
|
||||||
@@ -219,9 +283,19 @@
|
|||||||
private void EditProfile(TenantProfile p)
|
private void EditProfile(TenantProfile p)
|
||||||
{
|
{
|
||||||
_editing = p;
|
_editing = p;
|
||||||
_form = new TenantProfile { Id = p.Id, Name = p.Name, TenantUrl = p.TenantUrl, TenantId = p.TenantId, ClientId = p.ClientId, ClientLogo = p.ClientLogo };
|
_form = new TenantProfile
|
||||||
_showForm = true;
|
{
|
||||||
|
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;
|
_formError = _pageError = string.Empty;
|
||||||
|
_certPresent = CertStore.Exists(p.Id);
|
||||||
|
_pfxBytes = null;
|
||||||
|
_certPassword = string.Empty;
|
||||||
|
_appOnlyStatus = string.Empty;
|
||||||
}
|
}
|
||||||
|
|
||||||
private void CancelForm() { _showForm = false; _editing = null; }
|
private void CancelForm() { _showForm = false; _editing = null; }
|
||||||
@@ -256,14 +330,32 @@
|
|||||||
_regStatus = T["profiles.reg.creating"];
|
_regStatus = T["profiles.reg.creating"];
|
||||||
StateHasChanged();
|
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(
|
var clientId = await AppRegService.CreateAsync(
|
||||||
adminAccessToken: adminToken,
|
adminAccessToken: adminToken,
|
||||||
tenantName: _form.Name,
|
tenantName: _form.Name,
|
||||||
redirectUri: ConnectOpts.Value.RedirectUri,
|
redirectUri: ConnectOpts.Value.RedirectUri,
|
||||||
|
appOnlyCert: cert,
|
||||||
ct: _regCts.Token);
|
ct: _regCts.Token);
|
||||||
|
|
||||||
_form.ClientId = clientId;
|
_form.ClientId = clientId;
|
||||||
_regStatus = T["profiles.reg.registered"];
|
_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)
|
catch (OperationCanceledException)
|
||||||
{
|
{
|
||||||
@@ -317,6 +409,84 @@
|
|||||||
{
|
{
|
||||||
_profiles.RemoveAll(x => x.Id == p.Id);
|
_profiles.RemoveAll(x => x.Id == p.Id);
|
||||||
await ProfileRepo.SaveAsync(_profiles);
|
await ProfileRepo.SaveAsync(_profiles);
|
||||||
|
CertStore.Delete(p.Id);
|
||||||
if (Session.CurrentProfile?.Id == p.Id) await Session.ClearSessionAsync();
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,101 @@
|
|||||||
|
@page "/reports"
|
||||||
|
@attribute [Microsoft.AspNetCore.Authorization.Authorize]
|
||||||
|
@inject IUserSessionService Session
|
||||||
|
@inject SharepointToolbox.Web.Infrastructure.Persistence.GeneratedReportRepository ReportIndex
|
||||||
|
@inject Microsoft.Extensions.Options.IOptions<SharepointToolbox.Web.Core.Models.AppConfiguration> Cfg
|
||||||
|
@inject TranslationSource T
|
||||||
|
@rendermode InteractiveServer
|
||||||
|
@using SharepointToolbox.Web.Core.Models
|
||||||
|
@using SharepointToolbox.Web.Services.Session
|
||||||
|
|
||||||
|
<h1 class="page-title">Reports</h1>
|
||||||
|
<p class="page-subtitle">Generated reports for the selected client.</p>
|
||||||
|
|
||||||
|
@if (!Session.HasProfile) { <NoProfilePrompt /> return; }
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<div class="flex-row">
|
||||||
|
<div class="card-title">@Session.CurrentProfile!.Name <span class="count-badge">@_reports.Count</span></div>
|
||||||
|
<div class="spacer"></div>
|
||||||
|
<button class="btn btn-secondary btn-sm" @onclick="Reload">Refresh</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (_reports.Count == 0)
|
||||||
|
{
|
||||||
|
<div class="alert alert-info">No reports generated yet for this client. Schedules run automatically; an admin can create them under Scheduled Reports.</div>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<div class="data-table-wrap">
|
||||||
|
<table class="data-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Name</th><th>Type</th><th>Generated (UTC)</th><th>Size</th><th>Status</th><th></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@foreach (var r in _reports)
|
||||||
|
{
|
||||||
|
<tr>
|
||||||
|
<td>@(string.IsNullOrEmpty(r.Name) ? "—" : r.Name)</td>
|
||||||
|
<td>@r.Type</td>
|
||||||
|
<td>@r.GeneratedUtc.ToString("yyyy-MM-dd HH:mm")</td>
|
||||||
|
<td>@(r.Status == ReportRunStatus.Success ? $"{r.SizeBytes / 1024.0:F1} KB" : "—")</td>
|
||||||
|
<td>
|
||||||
|
@if (r.Status == ReportRunStatus.Success)
|
||||||
|
{
|
||||||
|
<span class="chip chip-green">Success</span>
|
||||||
|
@if (r.Emailed)
|
||||||
|
{
|
||||||
|
<span class="chip chip-blue" style="margin-left:4px">Emailed</span>
|
||||||
|
}
|
||||||
|
else if (!string.IsNullOrEmpty(r.EmailError))
|
||||||
|
{
|
||||||
|
<span class="chip chip-red" style="margin-left:4px" title="@r.EmailError">Email failed</span>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<span class="chip chip-red" title="@r.Error">Failed</span>
|
||||||
|
}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div class="flex-row" style="gap:6px;justify-content:flex-end">
|
||||||
|
@if (r.Status == ReportRunStatus.Success)
|
||||||
|
{
|
||||||
|
<a class="btn btn-secondary btn-sm" href="/reports/download/@r.Id" target="_blank" rel="noopener">Download</a>
|
||||||
|
}
|
||||||
|
<button class="btn btn-danger btn-sm" @onclick="() => DeleteAsync(r)">Delete</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
private List<GeneratedReport> _reports = new();
|
||||||
|
|
||||||
|
protected override async Task OnInitializedAsync() => await Reload();
|
||||||
|
|
||||||
|
private async Task Reload()
|
||||||
|
{
|
||||||
|
if (!Session.HasProfile) { _reports = new(); return; }
|
||||||
|
_reports = (await ReportIndex.LoadForProfileAsync(Session.CurrentProfile!.Id)).ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task DeleteAsync(GeneratedReport r)
|
||||||
|
{
|
||||||
|
// Remove the file (best-effort) then the index entry.
|
||||||
|
if (r.Status == ReportRunStatus.Success && !string.IsNullOrEmpty(r.FileName))
|
||||||
|
{
|
||||||
|
var path = System.IO.Path.Combine(Cfg.Value.ExportsFolder, r.ProfileId, System.IO.Path.GetFileName(r.FileName));
|
||||||
|
try { if (System.IO.File.Exists(path)) System.IO.File.Delete(path); } catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
await ReportIndex.DeleteAsync(r.Id);
|
||||||
|
await Reload();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,499 @@
|
|||||||
|
@page "/scheduled-reports"
|
||||||
|
@attribute [Microsoft.AspNetCore.Authorization.Authorize]
|
||||||
|
@inject IUserContextAccessor UserContext
|
||||||
|
@inject SharepointToolbox.Web.Infrastructure.Persistence.ScheduledReportRepository ScheduleRepo
|
||||||
|
@inject SharepointToolbox.Web.Infrastructure.Persistence.ProfileRepository ProfileRepo
|
||||||
|
@inject SharepointToolbox.Web.Services.Reports.IScheduledReportRunner Runner
|
||||||
|
@inject SharepointToolbox.Web.Services.Reports.ScheduledRunCoordinator Coordinator
|
||||||
|
@inject TranslationSource T
|
||||||
|
@rendermode InteractiveServer
|
||||||
|
@using SharepointToolbox.Web.Core.Models
|
||||||
|
@using SharepointToolbox.Web.Services.Export
|
||||||
|
|
||||||
|
<h1 class="page-title">Scheduled Reports</h1>
|
||||||
|
<p class="page-subtitle">Automatic report generation per client. Generated files appear under Reports and are downloadable there.</p>
|
||||||
|
|
||||||
|
@if (UserContext.Role != UserRole.Admin)
|
||||||
|
{
|
||||||
|
<div class="alert alert-info">Only administrators can manage scheduled reports.</div>
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (!string.IsNullOrEmpty(_pageMsg)) { <div class="alert alert-info" style="margin-bottom:12px">@_pageMsg</div> }
|
||||||
|
|
||||||
|
@if (_appOnlyProfiles.Count == 0)
|
||||||
|
{
|
||||||
|
<div class="alert alert-error">
|
||||||
|
No client has app-only access enabled. Open a client under <a href="/profiles">Client Profiles</a>,
|
||||||
|
enable scheduled reports, and upload its certificate first.
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<div class="flex-row" style="margin-bottom:16px">
|
||||||
|
<button class="btn btn-primary" @onclick="AddNew" disabled="@(_appOnlyProfiles.Count == 0)">New schedule</button>
|
||||||
|
<div class="spacer"></div>
|
||||||
|
@if (Coordinator.IsPaused)
|
||||||
|
{
|
||||||
|
<span class="chip chip-blue" style="align-self:center">Scheduler paused</span>
|
||||||
|
<button class="btn btn-secondary btn-sm" @onclick="ResumeScheduler">Resume scheduler</button>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<button class="btn btn-secondary btn-sm" @onclick="PauseScheduler">Pause scheduler</button>
|
||||||
|
}
|
||||||
|
<button class="btn btn-danger btn-sm" @onclick="StopAll">Stop all running</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (_schedules.Count == 0 && !_showForm)
|
||||||
|
{
|
||||||
|
<div class="alert alert-info">No schedules defined.</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@foreach (var s in _schedules)
|
||||||
|
{
|
||||||
|
<div class="card">
|
||||||
|
<div class="flex-row">
|
||||||
|
<div>
|
||||||
|
<div style="font-weight:600;font-size:15px">
|
||||||
|
@(string.IsNullOrEmpty(s.Name) ? "(unnamed)" : s.Name)
|
||||||
|
@if (!s.Enabled) { <span class="chip chip-blue" style="margin-left:6px">Disabled</span> }
|
||||||
|
</div>
|
||||||
|
<div class="text-muted">@ClientName(s.ProfileId) · @s.Type · @s.Format · @RecurrenceSummary(s.Recurrence)</div>
|
||||||
|
<div class="text-muted">
|
||||||
|
@(s.AllSites ? "All sites" : $"{s.SiteUrls.Count} site(s)") ·
|
||||||
|
Next: @(s.NextRunUtc?.ToString("yyyy-MM-dd HH:mm 'UTC'") ?? "—") ·
|
||||||
|
Last: @(s.LastRunUtc?.ToString("yyyy-MM-dd HH:mm 'UTC'") ?? "never")
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="spacer"></div>
|
||||||
|
<button class="btn btn-secondary btn-sm" @onclick="() => RunNowAsync(s)" disabled="@Coordinator.IsRunning(s.Id)">
|
||||||
|
@(Coordinator.IsRunning(s.Id) ? "Running…" : "Run now")
|
||||||
|
</button>
|
||||||
|
@if (Coordinator.IsRunning(s.Id))
|
||||||
|
{
|
||||||
|
<button class="btn btn-danger btn-sm" @onclick="() => Stop(s)">Stop</button>
|
||||||
|
}
|
||||||
|
<button class="btn btn-secondary btn-sm" @onclick="() => ToggleEnabledAsync(s)">@(s.Enabled ? "Disable" : "Enable")</button>
|
||||||
|
<button class="btn btn-secondary btn-sm" @onclick="() => Edit(s)">Edit</button>
|
||||||
|
<button class="btn btn-danger btn-sm" @onclick="() => DeleteAsync(s)">Delete</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (_showForm)
|
||||||
|
{
|
||||||
|
<div class="card" style="border-color:var(--accent)">
|
||||||
|
<div class="card-title">@(_editing is null ? "New schedule" : "Edit schedule")</div>
|
||||||
|
@if (!string.IsNullOrEmpty(_formError)) { <div class="alert alert-error">@_formError</div> }
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Name</label>
|
||||||
|
<input class="form-input" @bind="_form.Name" placeholder="e.g. Weekly permissions audit" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="form-group" style="flex:1">
|
||||||
|
<label class="form-label">Client</label>
|
||||||
|
<select class="form-input" @bind="_form.ProfileId">
|
||||||
|
@foreach (var p in _appOnlyProfiles)
|
||||||
|
{
|
||||||
|
<option value="@p.Id">@p.Name</option>
|
||||||
|
}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-group" style="flex:1">
|
||||||
|
<label class="form-label">Report type</label>
|
||||||
|
<select class="form-input" @bind="_form.Type">
|
||||||
|
@foreach (var t in Enum.GetValues<ReportType>())
|
||||||
|
{
|
||||||
|
<option value="@t">@t</option>
|
||||||
|
}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (_form.Type == ReportType.VersionCleanup)
|
||||||
|
{
|
||||||
|
<div class="alert alert-error">
|
||||||
|
<strong>Destructive action.</strong> Version Cleanup permanently <strong>deletes</strong> old file
|
||||||
|
versions across the selected sites every time it runs — unattended, with no confirmation.
|
||||||
|
The report is only a summary of what was deleted. Output is HTML (no CSV).
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="form-group" style="flex:1">
|
||||||
|
<label class="form-label">Format</label>
|
||||||
|
<select class="form-input" @bind="_form.Format" disabled="@(_form.Type == ReportType.VersionCleanup)">
|
||||||
|
@foreach (var f in Enum.GetValues<ReportFormat>())
|
||||||
|
{
|
||||||
|
<option value="@f">@f</option>
|
||||||
|
}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-group" style="flex:1">
|
||||||
|
<label class="form-label">Multi-site bundling</label>
|
||||||
|
<select class="form-input" @bind="_form.MergeMode">
|
||||||
|
@foreach (var m in Enum.GetValues<ReportMergeMode>())
|
||||||
|
{
|
||||||
|
<option value="@m">@m</option>
|
||||||
|
}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@* ── Site scope ── *@
|
||||||
|
<div class="form-group">
|
||||||
|
<label><input type="checkbox" @bind="_form.AllSites" /> All sites in the tenant (auto-discovered)</label>
|
||||||
|
@if (!_form.AllSites)
|
||||||
|
{
|
||||||
|
<label class="form-label mt-8">Site URLs (one per line)</label>
|
||||||
|
<textarea class="form-textarea" rows="3" @bind="_siteUrlsText"
|
||||||
|
placeholder="https://contoso.sharepoint.com/sites/Marketing"></textarea>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@* ── Recurrence ── *@
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="form-group" style="flex:1">
|
||||||
|
<label class="form-label">Frequency</label>
|
||||||
|
<select class="form-input" @bind="_form.Recurrence.Frequency">
|
||||||
|
@foreach (var f in Enum.GetValues<ReportFrequency>())
|
||||||
|
{
|
||||||
|
<option value="@f">@f</option>
|
||||||
|
}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-group" style="flex:1">
|
||||||
|
<label class="form-label">Time (UTC, HH:mm)</label>
|
||||||
|
<input class="form-input" @bind="_form.Recurrence.TimeOfDayUtc" placeholder="06:00" />
|
||||||
|
</div>
|
||||||
|
@if (_form.Recurrence.Frequency == ReportFrequency.Weekly)
|
||||||
|
{
|
||||||
|
<div class="form-group" style="flex:1">
|
||||||
|
<label class="form-label">Day of week</label>
|
||||||
|
<select class="form-input" @bind="_form.Recurrence.DayOfWeek">
|
||||||
|
@foreach (var d in Enum.GetValues<DayOfWeek>())
|
||||||
|
{
|
||||||
|
<option value="@d">@d</option>
|
||||||
|
}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
else if (_form.Recurrence.Frequency == ReportFrequency.Monthly)
|
||||||
|
{
|
||||||
|
<div class="form-group" style="flex:1">
|
||||||
|
<label class="form-label">Day of month</label>
|
||||||
|
<input class="form-input" type="number" min="1" max="31" @bind="_form.Recurrence.DayOfMonth" />
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@* ── Type-specific options ── *@
|
||||||
|
<div class="form-group" style="border-top:1px solid var(--border);padding-top:12px">
|
||||||
|
<label class="form-label" style="font-weight:600">Options</label>
|
||||||
|
@switch (_form.Type)
|
||||||
|
{
|
||||||
|
case ReportType.Permissions:
|
||||||
|
<div style="display:flex;gap:16px;flex-wrap:wrap;align-items:center">
|
||||||
|
<label><input type="checkbox" @bind="_form.Options.IncludeInherited" /> Include inherited</label>
|
||||||
|
<label><input type="checkbox" @bind="_form.Options.ScanFolders" /> Scan folders</label>
|
||||||
|
<label><input type="checkbox" @bind="_form.Options.IncludeSubsites" /> Include subsites</label>
|
||||||
|
<label>Folder depth <input class="form-input" type="number" min="0" max="999" style="width:80px" @bind="_form.Options.FolderDepth" /></label>
|
||||||
|
</div>
|
||||||
|
break;
|
||||||
|
|
||||||
|
case ReportType.Storage:
|
||||||
|
<div style="display:flex;gap:16px;flex-wrap:wrap;align-items:center">
|
||||||
|
<label><input type="checkbox" @bind="_form.Options.IncludeSubsites" /> Include subsites</label>
|
||||||
|
<label><input type="checkbox" @bind="_form.Options.IncludeHiddenLibraries" /> Include hidden libraries</label>
|
||||||
|
<label><input type="checkbox" @bind="_form.Options.IncludeRecycleBin" /> Include recycle bin</label>
|
||||||
|
<label>Folder depth <input class="form-input" type="number" min="0" max="20" style="width:80px" @bind="_form.Options.FolderDepth" /></label>
|
||||||
|
</div>
|
||||||
|
break;
|
||||||
|
|
||||||
|
case ReportType.Duplicates:
|
||||||
|
<div style="display:flex;gap:16px;flex-wrap:wrap;align-items:center">
|
||||||
|
<label>Mode
|
||||||
|
<select class="form-input" style="width:120px" @bind="_form.Options.DuplicateMode">
|
||||||
|
<option value="Files">Files</option>
|
||||||
|
<option value="Folders">Folders</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label><input type="checkbox" @bind="_form.Options.MatchSize" /> Match size</label>
|
||||||
|
<label><input type="checkbox" @bind="_form.Options.IncludeSubsites" /> Include subsites</label>
|
||||||
|
<label>Library <input class="form-input" style="width:160px" @bind="_form.Options.Library" placeholder="(all)" /></label>
|
||||||
|
</div>
|
||||||
|
break;
|
||||||
|
|
||||||
|
case ReportType.Search:
|
||||||
|
<div style="display:flex;gap:16px;flex-wrap:wrap;align-items:center">
|
||||||
|
<label>Extensions <input class="form-input" style="width:180px" @bind="_extensionsText" placeholder="pdf, docx" /></label>
|
||||||
|
<label>Regex <input class="form-input" style="width:180px" @bind="_form.Options.Regex" placeholder="(optional)" /></label>
|
||||||
|
<label>Max results <input class="form-input" type="number" min="1" style="width:100px" @bind="_form.Options.MaxResults" /></label>
|
||||||
|
<label>Library <input class="form-input" style="width:160px" @bind="_form.Options.Library" placeholder="(all)" /></label>
|
||||||
|
</div>
|
||||||
|
break;
|
||||||
|
|
||||||
|
case ReportType.UserAccess:
|
||||||
|
<label class="form-label">Target users (login/email, one per line)</label>
|
||||||
|
<textarea class="form-textarea" rows="2" @bind="_targetUsersText" placeholder="alice@contoso.com bob@contoso.com"></textarea>
|
||||||
|
<div style="display:flex;gap:16px;flex-wrap:wrap;align-items:center;margin-top:8px">
|
||||||
|
<label><input type="checkbox" @bind="_form.Options.IncludeInherited" /> Include inherited</label>
|
||||||
|
<label><input type="checkbox" @bind="_form.Options.IncludeSubsites" /> Include subsites</label>
|
||||||
|
</div>
|
||||||
|
break;
|
||||||
|
|
||||||
|
case ReportType.VersionCleanup:
|
||||||
|
<div style="display:flex;gap:16px;flex-wrap:wrap;align-items:center">
|
||||||
|
<label>Libraries (comma separated, blank = all) <input class="form-input" style="width:220px" @bind="_libraryTitlesText" /></label>
|
||||||
|
<label>Keep last <input class="form-input" type="number" min="0" style="width:80px" @bind="_form.Options.KeepLast" /></label>
|
||||||
|
<label><input type="checkbox" @bind="_form.Options.KeepFirst" /> Keep first version</label>
|
||||||
|
</div>
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@* ── Email delivery ── *@
|
||||||
|
<div class="form-group" style="border-top:1px solid var(--border);padding-top:12px">
|
||||||
|
<label style="font-weight:600"><input type="checkbox" @bind="_form.Email.Enabled" /> Email the report via Graph</label>
|
||||||
|
@if (_form.Email.Enabled)
|
||||||
|
{
|
||||||
|
<div class="alert alert-info" style="margin-top:8px">
|
||||||
|
Sent through the client's app-only certificate (requires the <strong>Mail.Send</strong> application
|
||||||
|
permission — re-run onboarding if the app was registered before this was added). The report file is attached.
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">From (sender mailbox)</label>
|
||||||
|
<input class="form-input" @bind="_form.Email.From" placeholder="reports@contoso.com" />
|
||||||
|
</div>
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="form-group" style="flex:1">
|
||||||
|
<label class="form-label">To (one per line)</label>
|
||||||
|
<textarea class="form-textarea" rows="2" @bind="_emailToText" placeholder="alice@contoso.com"></textarea>
|
||||||
|
</div>
|
||||||
|
<div class="form-group" style="flex:1">
|
||||||
|
<label class="form-label">Cc (one per line)</label>
|
||||||
|
<textarea class="form-textarea" rows="2" @bind="_emailCcText" placeholder="(optional)"></textarea>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Subject</label>
|
||||||
|
<input class="form-input" @bind="_form.Email.Subject" />
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Body (HTML)</label>
|
||||||
|
<textarea class="form-textarea" rows="5" @bind="_form.Email.Body"></textarea>
|
||||||
|
<div class="text-muted" style="margin-top:4px">
|
||||||
|
Placeholders: {ReportName} {ClientName} {ReportType} {FileName} {DateUtc}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label><input type="checkbox" @bind="_form.Enabled" /> Enabled</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex-row mt-8">
|
||||||
|
<button class="btn btn-primary" @onclick="SaveAsync">Save</button>
|
||||||
|
<button class="btn btn-secondary" @onclick="() => _showForm = false">Cancel</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@code {
|
||||||
|
private List<ScheduledReport> _schedules = new();
|
||||||
|
private List<TenantProfile> _appOnlyProfiles = new();
|
||||||
|
private bool _showForm;
|
||||||
|
private ScheduledReport? _editing;
|
||||||
|
private ScheduledReport _form = new();
|
||||||
|
private string _formError = string.Empty;
|
||||||
|
private string _pageMsg = string.Empty;
|
||||||
|
|
||||||
|
// Textarea/CSV scratch buffers mapped to/from the option lists on save.
|
||||||
|
private string _siteUrlsText = string.Empty;
|
||||||
|
private string _extensionsText = string.Empty;
|
||||||
|
private string _targetUsersText = string.Empty;
|
||||||
|
private string _libraryTitlesText = string.Empty;
|
||||||
|
private string _emailToText = string.Empty;
|
||||||
|
private string _emailCcText = string.Empty;
|
||||||
|
|
||||||
|
protected override async Task OnInitializedAsync()
|
||||||
|
{
|
||||||
|
if (UserContext.Role != UserRole.Admin) return;
|
||||||
|
await Reload();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task Reload()
|
||||||
|
{
|
||||||
|
_schedules = (await ScheduleRepo.LoadAsync()).OrderBy(s => s.Name).ToList();
|
||||||
|
_appOnlyProfiles = (await ProfileRepo.LoadAsync()).Where(p => p.AppOnlyEnabled).OrderBy(p => p.Name).ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
private string ClientName(string profileId) =>
|
||||||
|
_appOnlyProfiles.FirstOrDefault(p => p.Id == profileId)?.Name ?? "(client removed)";
|
||||||
|
|
||||||
|
private static string RecurrenceSummary(RecurrenceRule r) => r.Frequency switch
|
||||||
|
{
|
||||||
|
ReportFrequency.Daily => $"Daily at {r.TimeOfDayUtc} UTC",
|
||||||
|
ReportFrequency.Weekly => $"Weekly {r.DayOfWeek} at {r.TimeOfDayUtc} UTC",
|
||||||
|
ReportFrequency.Monthly => $"Monthly day {r.DayOfMonth} at {r.TimeOfDayUtc} UTC",
|
||||||
|
_ => r.TimeOfDayUtc
|
||||||
|
};
|
||||||
|
|
||||||
|
private void AddNew()
|
||||||
|
{
|
||||||
|
_editing = null;
|
||||||
|
_form = new ScheduledReport
|
||||||
|
{
|
||||||
|
ProfileId = _appOnlyProfiles.FirstOrDefault()?.Id ?? string.Empty,
|
||||||
|
CreatedBy = UserContext.Email
|
||||||
|
};
|
||||||
|
_siteUrlsText = _extensionsText = _targetUsersText = _libraryTitlesText = string.Empty;
|
||||||
|
_emailToText = _emailCcText = string.Empty;
|
||||||
|
_formError = string.Empty;
|
||||||
|
_showForm = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Edit(ScheduledReport s)
|
||||||
|
{
|
||||||
|
_editing = s;
|
||||||
|
// Deep-ish copy so cancel discards edits.
|
||||||
|
_form = new ScheduledReport
|
||||||
|
{
|
||||||
|
Id = s.Id, ProfileId = s.ProfileId, Name = s.Name, Type = s.Type,
|
||||||
|
AllSites = s.AllSites, SiteUrls = new List<string>(s.SiteUrls),
|
||||||
|
MergeMode = s.MergeMode, Format = s.Format, Enabled = s.Enabled,
|
||||||
|
CreatedBy = s.CreatedBy, CreatedUtc = s.CreatedUtc,
|
||||||
|
LastRunUtc = s.LastRunUtc, NextRunUtc = s.NextRunUtc,
|
||||||
|
Recurrence = new RecurrenceRule
|
||||||
|
{
|
||||||
|
Frequency = s.Recurrence.Frequency, TimeOfDayUtc = s.Recurrence.TimeOfDayUtc,
|
||||||
|
DayOfWeek = s.Recurrence.DayOfWeek, DayOfMonth = s.Recurrence.DayOfMonth
|
||||||
|
},
|
||||||
|
Options = Clone(s.Options),
|
||||||
|
Email = new ReportEmailSettings
|
||||||
|
{
|
||||||
|
Enabled = s.Email.Enabled, From = s.Email.From,
|
||||||
|
To = new List<string>(s.Email.To), Cc = new List<string>(s.Email.Cc),
|
||||||
|
Subject = s.Email.Subject, Body = s.Email.Body
|
||||||
|
}
|
||||||
|
};
|
||||||
|
_siteUrlsText = string.Join("\n", s.SiteUrls);
|
||||||
|
_extensionsText = string.Join(", ", s.Options.Extensions);
|
||||||
|
_targetUsersText = string.Join("\n", s.Options.TargetUserLogins);
|
||||||
|
_libraryTitlesText = string.Join(", ", s.Options.LibraryTitles);
|
||||||
|
_emailToText = string.Join("\n", s.Email.To);
|
||||||
|
_emailCcText = string.Join("\n", s.Email.Cc);
|
||||||
|
_formError = string.Empty;
|
||||||
|
_showForm = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ScheduledReportOptions Clone(ScheduledReportOptions o) => new()
|
||||||
|
{
|
||||||
|
IncludeInherited = o.IncludeInherited, ScanFolders = o.ScanFolders, FolderDepth = o.FolderDepth,
|
||||||
|
IncludeSubsites = o.IncludeSubsites, PerLibrary = o.PerLibrary,
|
||||||
|
IncludeHiddenLibraries = o.IncludeHiddenLibraries, IncludePreservationHold = o.IncludePreservationHold,
|
||||||
|
IncludeListAttachments = o.IncludeListAttachments, IncludeRecycleBin = o.IncludeRecycleBin,
|
||||||
|
DuplicateMode = o.DuplicateMode, MatchSize = o.MatchSize, MatchCreated = o.MatchCreated,
|
||||||
|
MatchModified = o.MatchModified, MatchSubfolderCount = o.MatchSubfolderCount, MatchFileCount = o.MatchFileCount,
|
||||||
|
Library = o.Library, LibraryTitles = new List<string>(o.LibraryTitles), KeepLast = o.KeepLast, KeepFirst = o.KeepFirst,
|
||||||
|
Extensions = new List<string>(o.Extensions), Regex = o.Regex, MaxResults = o.MaxResults,
|
||||||
|
TargetUserLogins = new List<string>(o.TargetUserLogins)
|
||||||
|
};
|
||||||
|
|
||||||
|
private async Task SaveAsync()
|
||||||
|
{
|
||||||
|
_formError = string.Empty;
|
||||||
|
if (string.IsNullOrWhiteSpace(_form.Name)) { _formError = "Name is required."; return; }
|
||||||
|
if (string.IsNullOrWhiteSpace(_form.ProfileId)) { _formError = "Select a client."; return; }
|
||||||
|
|
||||||
|
// VersionCleanup has no CSV exporter.
|
||||||
|
if (_form.Type == ReportType.VersionCleanup) _form.Format = ReportFormat.Html;
|
||||||
|
|
||||||
|
// Map scratch buffers back to option lists.
|
||||||
|
_form.SiteUrls = _siteUrlsText.Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries).ToList();
|
||||||
|
_form.Options.Extensions = _extensionsText.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries).ToList();
|
||||||
|
_form.Options.TargetUserLogins = _targetUsersText.Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries).ToList();
|
||||||
|
_form.Options.LibraryTitles = _libraryTitlesText.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries).ToList();
|
||||||
|
|
||||||
|
if (!_form.AllSites && _form.SiteUrls.Count == 0) { _formError = "Add at least one site URL, or choose All sites."; return; }
|
||||||
|
if (_form.Type == ReportType.UserAccess && _form.Options.TargetUserLogins.Count == 0) { _formError = "User Access reports need at least one target user."; return; }
|
||||||
|
|
||||||
|
// Map email scratch buffers back to lists and validate when delivery is on.
|
||||||
|
_form.Email.To = _emailToText.Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries).ToList();
|
||||||
|
_form.Email.Cc = _emailCcText.Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries).ToList();
|
||||||
|
if (_form.Email.Enabled)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(_form.Email.From)) { _formError = "Email delivery needs a sender mailbox (From)."; return; }
|
||||||
|
if (_form.Email.To.Count == 0 && _form.Email.Cc.Count == 0) { _formError = "Email delivery needs at least one To or Cc recipient."; return; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Arm the next run from now so the scheduler picks it up on the right cadence.
|
||||||
|
_form.NextRunUtc = _form.Recurrence.ComputeNextRunUtc(DateTime.UtcNow);
|
||||||
|
|
||||||
|
await ScheduleRepo.UpsertAsync(_form);
|
||||||
|
_showForm = false;
|
||||||
|
_editing = null;
|
||||||
|
await Reload();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task ToggleEnabledAsync(ScheduledReport s)
|
||||||
|
{
|
||||||
|
s.Enabled = !s.Enabled;
|
||||||
|
if (s.Enabled && s.NextRunUtc is null) s.NextRunUtc = s.Recurrence.ComputeNextRunUtc(DateTime.UtcNow);
|
||||||
|
await ScheduleRepo.UpsertAsync(s);
|
||||||
|
await Reload();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task DeleteAsync(ScheduledReport s)
|
||||||
|
{
|
||||||
|
await ScheduleRepo.DeleteAsync(s.Id);
|
||||||
|
await Reload();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task RunNowAsync(ScheduledReport s)
|
||||||
|
{
|
||||||
|
// Register through the coordinator so this manual run is stoppable and can't
|
||||||
|
// overlap a scheduler-triggered run of the same schedule.
|
||||||
|
var token = Coordinator.TryBegin(s.Id, CancellationToken.None);
|
||||||
|
if (token is null) { _pageMsg = $"'{s.Name}' is already running."; return; }
|
||||||
|
|
||||||
|
_pageMsg = string.Empty;
|
||||||
|
await InvokeAsync(StateHasChanged);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var report = await Runner.RunAsync(s, token.Value);
|
||||||
|
_pageMsg = report.Status == ReportRunStatus.Success
|
||||||
|
? $"'{s.Name}' generated {report.FileName}. See Reports."
|
||||||
|
: $"'{s.Name}' failed: {report.Error}";
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException) { _pageMsg = $"'{s.Name}' was stopped."; }
|
||||||
|
catch (Exception ex) { _pageMsg = $"'{s.Name}' failed: {ex.Message}"; }
|
||||||
|
finally { Coordinator.Complete(s.Id); await Reload(); }
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Stop(ScheduledReport s) =>
|
||||||
|
_pageMsg = Coordinator.Cancel(s.Id)
|
||||||
|
? $"Stop signal sent to '{s.Name}'. It ends after the current site finishes."
|
||||||
|
: $"'{s.Name}' is not running.";
|
||||||
|
|
||||||
|
private void StopAll()
|
||||||
|
{
|
||||||
|
var n = Coordinator.CancelAll();
|
||||||
|
_pageMsg = n == 0 ? "No runs in progress." : $"Stop signal sent to {n} running report(s).";
|
||||||
|
}
|
||||||
|
|
||||||
|
private void PauseScheduler()
|
||||||
|
{
|
||||||
|
Coordinator.Pause();
|
||||||
|
_pageMsg = "Scheduler paused — no schedules will fire until resumed. Runs in progress keep going (Stop them individually).";
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ResumeScheduler()
|
||||||
|
{
|
||||||
|
Coordinator.Resume();
|
||||||
|
_pageMsg = "Scheduler resumed.";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@
|
|||||||
@inject ISessionManager SessionMgr
|
@inject ISessionManager SessionMgr
|
||||||
@inject IUserAccessAuditService AuditSvc
|
@inject IUserAccessAuditService AuditSvc
|
||||||
@inject IGraphUserDirectoryService GraphSvc
|
@inject IGraphUserDirectoryService GraphSvc
|
||||||
|
@inject ISiteDiscoveryService SiteDiscovery
|
||||||
@inject UserAccessCsvExportService CsvExport
|
@inject UserAccessCsvExportService CsvExport
|
||||||
@inject UserAccessHtmlExportService HtmlExport
|
@inject UserAccessHtmlExportService HtmlExport
|
||||||
@inject WebExportService WebExport
|
@inject WebExportService WebExport
|
||||||
@@ -56,6 +57,7 @@
|
|||||||
<textarea class="form-textarea" @bind="_users" placeholder="alice@contoso.com bob@contoso.com" rows="2"></textarea>
|
<textarea class="form-textarea" @bind="_users" placeholder="alice@contoso.com bob@contoso.com" rows="2"></textarea>
|
||||||
</div>
|
</div>
|
||||||
<SitePicker Profile="Session.CurrentProfile!" @bind-SelectedSites="_sites" />
|
<SitePicker Profile="Session.CurrentProfile!" @bind-SelectedSites="_sites" />
|
||||||
|
<div class="text-muted" style="margin-top:4px">@T["audit.hint.allSites"]</div>
|
||||||
<div class="form-row">
|
<div class="form-row">
|
||||||
<div class="form-group" style="display:flex;align-items:center;gap:16px;padding-top:20px">
|
<div class="form-group" style="display:flex;align-items:center;gap:16px;padding-top:20px">
|
||||||
<label><input type="checkbox" @bind="_includeInherited" /> @T["audit.chk.includeInherited"]<HelpTip Text="@T["help.inheritedPerms"]" /></label>
|
<label><input type="checkbox" @bind="_includeInherited" /> @T["audit.chk.includeInherited"]<HelpTip Text="@T["help.inheritedPerms"]" /></label>
|
||||||
@@ -79,29 +81,92 @@
|
|||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="flex-row">
|
<div class="flex-row">
|
||||||
<div class="card-title">@T["audit.results.title"] <span class="count-badge">@_results.Count</span></div>
|
<div class="card-title">@T["audit.results.title"] <span class="count-badge">@_results.Count</span></div>
|
||||||
|
<button class="btn btn-sm @(_viewMode == "site" ? "btn-primary" : "btn-secondary")" @onclick='() => _viewMode = "site"'>@T["audit.view.bySite"]</button>
|
||||||
|
<button class="btn btn-sm @(_viewMode == "flat" ? "btn-primary" : "btn-secondary")" @onclick='() => _viewMode = "flat"'>@T["audit.view.table"]</button>
|
||||||
<div class="spacer"></div>
|
<div class="spacer"></div>
|
||||||
<button class="btn btn-secondary btn-sm" @onclick="ExportCsv">@T["audit.btn.exportCsv"]</button>
|
<button class="btn btn-secondary btn-sm" @onclick="ExportCsv">@T["audit.btn.exportCsv"]</button>
|
||||||
<button class="btn btn-secondary btn-sm" @onclick="ExportHtml">@T["audit.btn.exportHtml"]</button>
|
<button class="btn btn-secondary btn-sm" @onclick="ExportHtml">@T["audit.btn.exportHtml"]</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="data-table-wrap">
|
|
||||||
<table class="data-table">
|
@if (_sitesScanned > 0)
|
||||||
<thead><tr><th>@T["report.col.user"]</th><th>@T["report.col.site"]</th><th>@T["report.col.object"]</th><th>@T["audit.col.permission"]<HelpTip Text="@T["help.permissionLevel"]" /></th><th>@T["report.col.access_type"]<HelpTip Text="@T["help.accessType"]" Wide="true" /></th><th>@T["report.col.granted_through"]<HelpTip Text="@T["help.grantedThrough"]" /></th></tr></thead>
|
{
|
||||||
<tbody>
|
<div class="flex-row mt-8" style="gap:8px">
|
||||||
@foreach (var r in _results.Take(500))
|
<span class="chip chip-blue">@string.Format(T["audit.scan.sitesScanned"], _sitesScanned)</span>
|
||||||
|
@if (_sitesDenied > 0) { <span class="chip chip-yellow">@string.Format(T["audit.scan.sitesDenied"], _sitesDenied)</span> }
|
||||||
|
@if (_sitesFailed > 0) { <span class="chip chip-red">@string.Format(T["audit.scan.sitesFailed"], _sitesFailed)</span> }
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (_viewMode == "site")
|
||||||
|
{
|
||||||
|
<div class="text-muted mt-8" style="margin-bottom:8px">@T["audit.bysite.hint"]</div>
|
||||||
|
@foreach (var g in _results.GroupBy(r => (r.SiteUrl, r.SiteTitle)).OrderBy(g => g.Key.SiteTitle, StringComparer.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
var siteUrl = g.Key.SiteUrl;
|
||||||
|
var expanded = _expandedSites.Contains(siteUrl);
|
||||||
|
var hasHigh = g.Any(e => e.IsHighPrivilege);
|
||||||
|
<div class="site-drill @(expanded ? "open" : "")">
|
||||||
|
<button class="site-drill-header" @onclick="() => ToggleSite(siteUrl)">
|
||||||
|
<span class="drill-caret @(expanded ? "open" : "")">▸</span>
|
||||||
|
<span class="drill-title">@g.Key.SiteTitle</span>
|
||||||
|
<span class="text-muted drill-url">@g.Key.SiteUrl</span>
|
||||||
|
<span class="spacer"></span>
|
||||||
|
@if (hasHigh) { <span class="chip chip-red">@T["audit.chip.high"]</span> }
|
||||||
|
<span class="count-badge">@g.Count() @T["report.text.permissions_parens"]</span>
|
||||||
|
</button>
|
||||||
|
@if (expanded)
|
||||||
{
|
{
|
||||||
<tr>
|
<div class="site-drill-body">
|
||||||
<td>@r.UserDisplayName</td>
|
<div class="data-table-wrap">
|
||||||
<td>@r.SiteTitle</td>
|
<table class="data-table">
|
||||||
<td>@r.ObjectTitle <span class="text-muted">(@r.ObjectType)</span></td>
|
<thead><tr>
|
||||||
<td>@r.PermissionLevel @if (r.IsHighPrivilege) { <span class="chip chip-red">@T["audit.chip.high"]</span> }</td>
|
@if (_multiUser) { <th>@T["report.col.user"]</th> }
|
||||||
<td>@r.AccessType</td>
|
<th>@T["report.col.object"]</th>
|
||||||
<td>@r.GrantedThrough</td>
|
<th>@T["audit.col.permission"]<HelpTip Text="@T["help.permissionLevel"]" /></th>
|
||||||
</tr>
|
<th>@T["report.col.access_type"]<HelpTip Text="@T["help.accessType"]" Wide="true" /></th>
|
||||||
|
<th>@T["report.col.granted_through"]<HelpTip Text="@T["help.grantedThrough"]" /></th>
|
||||||
|
</tr></thead>
|
||||||
|
<tbody>
|
||||||
|
@foreach (var r in g)
|
||||||
|
{
|
||||||
|
<tr>
|
||||||
|
@if (_multiUser) { <td>@r.UserDisplayName</td> }
|
||||||
|
<td>@r.ObjectTitle <span class="text-muted">(@r.ObjectType)</span></td>
|
||||||
|
<td>@r.PermissionLevel @if (r.IsHighPrivilege) { <span class="chip chip-red">@T["audit.chip.high"]</span> }</td>
|
||||||
|
<td>@r.AccessType</td>
|
||||||
|
<td>@r.GrantedThrough</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
}
|
}
|
||||||
</tbody>
|
</div>
|
||||||
</table>
|
}
|
||||||
</div>
|
}
|
||||||
@if (_results.Count > 500) { <div class="text-muted mt-8">@T["audit.msg.showFirst500Export"]</div> }
|
else
|
||||||
|
{
|
||||||
|
<div class="data-table-wrap">
|
||||||
|
<table class="data-table">
|
||||||
|
<thead><tr><th>@T["report.col.user"]</th><th>@T["report.col.site"]</th><th>@T["report.col.object"]</th><th>@T["audit.col.permission"]<HelpTip Text="@T["help.permissionLevel"]" /></th><th>@T["report.col.access_type"]<HelpTip Text="@T["help.accessType"]" Wide="true" /></th><th>@T["report.col.granted_through"]<HelpTip Text="@T["help.grantedThrough"]" /></th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
@foreach (var r in _results.Take(500))
|
||||||
|
{
|
||||||
|
<tr>
|
||||||
|
<td>@r.UserDisplayName</td>
|
||||||
|
<td>@r.SiteTitle</td>
|
||||||
|
<td>@r.ObjectTitle <span class="text-muted">(@r.ObjectType)</span></td>
|
||||||
|
<td>@r.PermissionLevel @if (r.IsHighPrivilege) { <span class="chip chip-red">@T["audit.chip.high"]</span> }</td>
|
||||||
|
<td>@r.AccessType</td>
|
||||||
|
<td>@r.GrantedThrough</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
@if (_results.Count > 500) { <div class="text-muted mt-8">@T["audit.msg.showFirst500Export"]</div> }
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -147,26 +212,58 @@
|
|||||||
private bool _running; private string _status = string.Empty, _error = string.Empty;
|
private bool _running; private string _status = string.Empty, _error = string.Empty;
|
||||||
private int _current, _total;
|
private int _current, _total;
|
||||||
private List<UserAccessEntry> _results = new();
|
private List<UserAccessEntry> _results = new();
|
||||||
|
private int _sitesScanned, _sitesDenied, _sitesFailed;
|
||||||
private CancellationTokenSource? _cts;
|
private CancellationTokenSource? _cts;
|
||||||
|
|
||||||
|
// Results presentation: "site" = drill-down grouped by site (pick user → sites → click → detail);
|
||||||
|
// "flat" = the original per-entry table. Site view is default and matches the single-user flow.
|
||||||
|
private string _viewMode = "site";
|
||||||
|
private readonly HashSet<string> _expandedSites = new(StringComparer.OrdinalIgnoreCase);
|
||||||
|
// Show the User column inside the per-site detail only when the audit spans multiple users.
|
||||||
|
private bool _multiUser => _results.Select(r => r.UserLogin).Distinct(StringComparer.OrdinalIgnoreCase).Count() > 1;
|
||||||
|
|
||||||
|
private void ToggleSite(string siteUrl)
|
||||||
|
{
|
||||||
|
if (!_expandedSites.Remove(siteUrl)) _expandedSites.Add(siteUrl);
|
||||||
|
}
|
||||||
|
|
||||||
private async Task RunAudit()
|
private async Task RunAudit()
|
||||||
{
|
{
|
||||||
_error = string.Empty; _results.Clear(); _running = true;
|
_error = string.Empty; _results.Clear(); _expandedSites.Clear();
|
||||||
|
_sitesScanned = _sitesDenied = _sitesFailed = 0; _running = true;
|
||||||
_cts = new CancellationTokenSource();
|
_cts = new CancellationTokenSource();
|
||||||
var userList = _selectedEmails
|
var userList = _selectedEmails
|
||||||
.Concat(_users.Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
|
.Concat(_users.Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
|
||||||
.Distinct(StringComparer.OrdinalIgnoreCase).ToList();
|
.Distinct(StringComparer.OrdinalIgnoreCase).ToList();
|
||||||
if (!userList.Any()) { _error = T["audit.err.noUsersOrEmail"]; _running = false; return; }
|
if (!userList.Any()) { _error = T["audit.err.noUsersOrEmail"]; _running = false; return; }
|
||||||
var siteList = _sites.ToList();
|
var siteList = _sites.ToList();
|
||||||
if (!siteList.Any()) siteList.Add(new SiteInfo(Session.CurrentProfile!.TenantUrl, Session.CurrentProfile.Name));
|
// No explicit selection → audit every site in the tenant. The scan itself is resilient
|
||||||
|
// (per-site errors are skipped) so a tenant-wide run completes despite denied/failed sites.
|
||||||
|
if (siteList.Count == 0)
|
||||||
|
{
|
||||||
|
_status = T["audit.status.discoveringSites"];
|
||||||
|
await InvokeAsync(StateHasChanged);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
siteList = (await SiteDiscovery.SearchSitesAsync(Session.CurrentProfile!, null, _cts.Token)).ToList();
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException) { _status = T["audit.status.cancelled"]; _running = false; return; }
|
||||||
|
catch (Exception ex) { _error = string.Format(T["audit.err.discoverFailed"], ex.Message); _running = false; return; }
|
||||||
|
|
||||||
|
// Enumeration came back empty → fall back to the profile root site.
|
||||||
|
if (siteList.Count == 0)
|
||||||
|
siteList.Add(new SiteInfo(Session.CurrentProfile!.TenantUrl, Session.CurrentProfile.Name));
|
||||||
|
}
|
||||||
var progress = new Progress<OperationProgress>(p => { _status = p.Message; _current = p.Current; _total = p.Total; InvokeAsync(StateHasChanged); });
|
var progress = new Progress<OperationProgress>(p => { _status = p.Message; _current = p.Current; _total = p.Total; InvokeAsync(StateHasChanged); });
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var opts = new ScanOptions(_includeInherited, _scanFolders, 1, _includeSubsites);
|
var opts = new ScanOptions(_includeInherited, _scanFolders, 1, _includeSubsites);
|
||||||
_results = (await AuditSvc.AuditUsersAsync(SessionMgr, Session.CurrentProfile!, userList, siteList, opts, progress, _cts.Token)).ToList();
|
var res = await AuditSvc.AuditUsersAsync(SessionMgr, Session.CurrentProfile!, userList, siteList, opts, progress, _cts.Token);
|
||||||
|
_results = res.Entries.ToList();
|
||||||
|
_sitesScanned = res.SitesScanned; _sitesDenied = res.SitesDenied; _sitesFailed = res.SitesFailed;
|
||||||
_status = string.Format(T["audit.status.found"], _results.Count);
|
_status = string.Format(T["audit.status.found"], _results.Count);
|
||||||
await Audit.LogAsync("UserAccessAudit", Session.CurrentProfile?.Name ?? "", siteList.Select(s => s.Url),
|
await Audit.LogAsync("UserAccessAudit", Session.CurrentProfile?.Name ?? "", siteList.Select(s => s.Url),
|
||||||
$"{_results.Count} entries for {userList.Count} user(s)");
|
$"{_results.Count} entries for {userList.Count} user(s); {res.SitesScanned} sites, {res.SitesDenied} denied, {res.SitesFailed} failed");
|
||||||
}
|
}
|
||||||
catch (OperationCanceledException) { _status = T["audit.status.cancelled"]; }
|
catch (OperationCanceledException) { _status = T["audit.status.cancelled"]; }
|
||||||
catch (Exception ex) { _error = ex.Message; }
|
catch (Exception ex) { _error = ex.Message; }
|
||||||
|
|||||||
@@ -0,0 +1,92 @@
|
|||||||
|
using Microsoft.Online.SharePoint.TenantAdministration;
|
||||||
|
using Microsoft.SharePoint.Client;
|
||||||
|
using SharepointToolbox.Web.Core.Models;
|
||||||
|
|
||||||
|
namespace SharepointToolbox.Web.Core.Helpers;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Enumerates every site collection in a tenant via the SharePoint tenant-admin
|
||||||
|
/// endpoint (<c>Tenant.GetSitePropertiesFromSharePointByFilters</c>), paging through
|
||||||
|
/// all results. Shared by the delegated <c>SiteDiscoveryService</c> and the app-only
|
||||||
|
/// background report scheduler so both produce the identical, complete site set.
|
||||||
|
///
|
||||||
|
/// The caller supplies a <see cref="ClientContext"/> already pointed at the tenant
|
||||||
|
/// admin host (see <see cref="BuildAdminUrl"/>) and authenticated by whichever model
|
||||||
|
/// applies. The cold-token 403 retry handles the transient denial a freshly minted
|
||||||
|
/// delegated token hits against the admin host; it is harmless under app-only auth.
|
||||||
|
/// </summary>
|
||||||
|
public static class TenantSiteEnumerator
|
||||||
|
{
|
||||||
|
private const int MaxColdTokenAttempts = 4;
|
||||||
|
private const int BackoffBaseSeconds = 3;
|
||||||
|
|
||||||
|
public static async Task<IReadOnlyList<SiteInfo>> EnumerateAsync(ClientContext adminCtx, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var tenant = new Tenant(adminCtx);
|
||||||
|
var filter = new SPOSitePropertiesEnumerableFilter
|
||||||
|
{
|
||||||
|
IncludeDetail = false,
|
||||||
|
IncludePersonalSite = PersonalSiteFilter.Exclude,
|
||||||
|
StartIndex = null,
|
||||||
|
Template = null,
|
||||||
|
};
|
||||||
|
|
||||||
|
var results = new List<SiteInfo>();
|
||||||
|
SPOSitePropertiesEnumerable page;
|
||||||
|
do
|
||||||
|
{
|
||||||
|
ct.ThrowIfCancellationRequested();
|
||||||
|
page = await FetchPageWithColdTokenRetryAsync(adminCtx, tenant, filter, ct);
|
||||||
|
foreach (var sp in page)
|
||||||
|
{
|
||||||
|
var url = sp.Url ?? string.Empty;
|
||||||
|
if (string.IsNullOrEmpty(url)) continue;
|
||||||
|
if (url.Contains("/personal/", StringComparison.OrdinalIgnoreCase)) continue;
|
||||||
|
var title = string.IsNullOrEmpty(sp.Title) ? url : sp.Title;
|
||||||
|
results.Add(new SiteInfo(url, title));
|
||||||
|
}
|
||||||
|
filter.StartIndex = page.NextStartIndexFromSharePoint;
|
||||||
|
}
|
||||||
|
while (!string.IsNullOrEmpty(filter.StartIndex));
|
||||||
|
|
||||||
|
return results
|
||||||
|
.GroupBy(s => s.Url, StringComparer.OrdinalIgnoreCase)
|
||||||
|
.Select(g => g.First())
|
||||||
|
.OrderBy(s => s.Title, StringComparer.OrdinalIgnoreCase)
|
||||||
|
.ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<SPOSitePropertiesEnumerable> FetchPageWithColdTokenRetryAsync(
|
||||||
|
ClientContext ctx, Tenant tenant, SPOSitePropertiesEnumerableFilter filter, CancellationToken ct)
|
||||||
|
{
|
||||||
|
for (int attempt = 1; ; attempt++)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var page = tenant.GetSitePropertiesFromSharePointByFilters(filter);
|
||||||
|
ctx.Load(page);
|
||||||
|
await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(ctx, null, ct);
|
||||||
|
return page;
|
||||||
|
}
|
||||||
|
catch (SharePointAccessDeniedException ex) when (attempt < MaxColdTokenAttempts)
|
||||||
|
{
|
||||||
|
var delay = TimeSpan.FromSeconds(BackoffBaseSeconds * attempt);
|
||||||
|
Serilog.Log.Warning(
|
||||||
|
"Tenant admin endpoint denied during site enumeration (attempt {N}/{Max}); " +
|
||||||
|
"retrying in {Delay}s. {Err}",
|
||||||
|
attempt, MaxColdTokenAttempts, delay.TotalSeconds, ex.Message);
|
||||||
|
await Task.Delay(delay, ct);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>https://contoso.sharepoint.com[/sites/Foo] → https://contoso-admin.sharepoint.com</summary>
|
||||||
|
public static string BuildAdminUrl(string tenantUrl)
|
||||||
|
{
|
||||||
|
if (!Uri.TryCreate(tenantUrl, UriKind.Absolute, out var uri))
|
||||||
|
return tenantUrl;
|
||||||
|
var adminHost = uri.Host.Replace(".sharepoint.com", "-admin.sharepoint.com",
|
||||||
|
StringComparison.OrdinalIgnoreCase);
|
||||||
|
return $"{uri.Scheme}://{adminHost}";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,4 +4,7 @@ public class AppConfiguration
|
|||||||
{
|
{
|
||||||
public string DataFolder { get; set; } = "/data";
|
public string DataFolder { get; set; } = "/data";
|
||||||
public string ExportsFolder { get; set; } = "/data/exports";
|
public string ExportsFolder { get; set; } = "/data/exports";
|
||||||
|
|
||||||
|
/// <summary>DataProtection-encrypted app-only certificates, one file per client profile.</summary>
|
||||||
|
public string CertsFolder { get; set; } = "/data/appcerts";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
namespace SharepointToolbox.Web.Core.Models;
|
||||||
|
|
||||||
|
public enum ReportRunStatus
|
||||||
|
{
|
||||||
|
Success,
|
||||||
|
Failed
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// One produced report file, listed per client. The file itself lives under
|
||||||
|
/// {ExportsFolder}/{ProfileId}/{FileName}; this record is the index entry that the
|
||||||
|
/// "Reports" list and the id-based download endpoint resolve against.
|
||||||
|
/// Persisted to reports-index.json.
|
||||||
|
/// </summary>
|
||||||
|
public class GeneratedReport
|
||||||
|
{
|
||||||
|
public string Id { get; set; } = Guid.NewGuid().ToString();
|
||||||
|
|
||||||
|
public string ProfileId { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>The schedule that produced this; null for an ad-hoc/manual run.</summary>
|
||||||
|
public string? ScheduledReportId { get; set; }
|
||||||
|
|
||||||
|
public ReportType Type { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Human label (usually copied from the schedule name).</summary>
|
||||||
|
public string Name { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>File name on disk, relative to the profile's exports subfolder.</summary>
|
||||||
|
public string FileName { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
public string Mime { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
public long SizeBytes { get; set; }
|
||||||
|
|
||||||
|
public DateTime GeneratedUtc { get; set; } = DateTime.UtcNow;
|
||||||
|
|
||||||
|
public ReportRunStatus Status { get; set; } = ReportRunStatus.Success;
|
||||||
|
|
||||||
|
/// <summary>Populated when <see cref="Status"/> is Failed.</summary>
|
||||||
|
public string? Error { get; set; }
|
||||||
|
|
||||||
|
/// <summary>True when the report was successfully emailed via Graph.</summary>
|
||||||
|
public bool Emailed { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Populated when email delivery was requested but failed. The report itself still
|
||||||
|
/// succeeded (file is on disk) — only delivery failed.
|
||||||
|
/// </summary>
|
||||||
|
public string? EmailError { get; set; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
namespace SharepointToolbox.Web.Core.Models;
|
||||||
|
|
||||||
|
/// <summary>The kinds of report that can be generated, scheduled, and exported.</summary>
|
||||||
|
public enum ReportType
|
||||||
|
{
|
||||||
|
Permissions,
|
||||||
|
Storage,
|
||||||
|
Duplicates,
|
||||||
|
UserAccess,
|
||||||
|
VersionCleanup,
|
||||||
|
Search
|
||||||
|
}
|
||||||
@@ -0,0 +1,196 @@
|
|||||||
|
using SharepointToolbox.Web.Services.Export;
|
||||||
|
|
||||||
|
namespace SharepointToolbox.Web.Core.Models;
|
||||||
|
|
||||||
|
/// <summary>How often a scheduled report recurs.</summary>
|
||||||
|
public enum ReportFrequency
|
||||||
|
{
|
||||||
|
Daily,
|
||||||
|
Weekly,
|
||||||
|
Monthly
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A recurrence rule. The report fires at <see cref="TimeOfDayUtc"/> on the cadence
|
||||||
|
/// described by <see cref="Frequency"/>. <see cref="DayOfWeek"/> applies to Weekly;
|
||||||
|
/// <see cref="DayOfMonth"/> applies to Monthly (clamped to the last day of short months).
|
||||||
|
/// All times are UTC to keep scheduling unambiguous across DST.
|
||||||
|
/// </summary>
|
||||||
|
public class RecurrenceRule
|
||||||
|
{
|
||||||
|
public ReportFrequency Frequency { get; set; } = ReportFrequency.Weekly;
|
||||||
|
|
||||||
|
/// <summary>Time of day to run, UTC, "HH:mm".</summary>
|
||||||
|
public string TimeOfDayUtc { get; set; } = "06:00";
|
||||||
|
|
||||||
|
/// <summary>0 = Sunday … 6 = Saturday. Used when <see cref="Frequency"/> is Weekly.</summary>
|
||||||
|
public DayOfWeek DayOfWeek { get; set; } = DayOfWeek.Monday;
|
||||||
|
|
||||||
|
/// <summary>1–31. Used when <see cref="Frequency"/> is Monthly.</summary>
|
||||||
|
public int DayOfMonth { get; set; } = 1;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Computes the next fire time strictly after <paramref name="afterUtc"/>.
|
||||||
|
/// </summary>
|
||||||
|
public DateTime ComputeNextRunUtc(DateTime afterUtc)
|
||||||
|
{
|
||||||
|
var (hh, mm) = ParseTime(TimeOfDayUtc);
|
||||||
|
|
||||||
|
switch (Frequency)
|
||||||
|
{
|
||||||
|
case ReportFrequency.Daily:
|
||||||
|
{
|
||||||
|
var candidate = new DateTime(afterUtc.Year, afterUtc.Month, afterUtc.Day, hh, mm, 0, DateTimeKind.Utc);
|
||||||
|
if (candidate <= afterUtc) candidate = candidate.AddDays(1);
|
||||||
|
return candidate;
|
||||||
|
}
|
||||||
|
|
||||||
|
case ReportFrequency.Weekly:
|
||||||
|
{
|
||||||
|
var candidate = new DateTime(afterUtc.Year, afterUtc.Month, afterUtc.Day, hh, mm, 0, DateTimeKind.Utc);
|
||||||
|
int delta = ((int)DayOfWeek - (int)candidate.DayOfWeek + 7) % 7;
|
||||||
|
candidate = candidate.AddDays(delta);
|
||||||
|
if (candidate <= afterUtc) candidate = candidate.AddDays(7);
|
||||||
|
return candidate;
|
||||||
|
}
|
||||||
|
|
||||||
|
case ReportFrequency.Monthly:
|
||||||
|
default:
|
||||||
|
{
|
||||||
|
var candidate = BuildMonthly(afterUtc.Year, afterUtc.Month, hh, mm);
|
||||||
|
if (candidate <= afterUtc)
|
||||||
|
{
|
||||||
|
var next = afterUtc.AddMonths(1);
|
||||||
|
candidate = BuildMonthly(next.Year, next.Month, hh, mm);
|
||||||
|
}
|
||||||
|
return candidate;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private DateTime BuildMonthly(int year, int month, int hh, int mm)
|
||||||
|
{
|
||||||
|
int day = Math.Min(DayOfMonth, DateTime.DaysInMonth(year, month));
|
||||||
|
return new DateTime(year, month, day, hh, mm, 0, DateTimeKind.Utc);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static (int Hour, int Minute) ParseTime(string s)
|
||||||
|
{
|
||||||
|
var parts = (s ?? "06:00").Split(':');
|
||||||
|
int hh = parts.Length > 0 && int.TryParse(parts[0], out var h) ? Math.Clamp(h, 0, 23) : 6;
|
||||||
|
int mm = parts.Length > 1 && int.TryParse(parts[1], out var m) ? Math.Clamp(m, 0, 59) : 0;
|
||||||
|
return (hh, mm);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Flat, serializable bag of the report-generation options used across all report
|
||||||
|
/// types. Only the fields relevant to the chosen <see cref="ScheduledReport.Type"/>
|
||||||
|
/// are honoured; the runner maps them to the concrete option records
|
||||||
|
/// (<see cref="ScanOptions"/>, <see cref="StorageScanOptions"/>, …).
|
||||||
|
/// </summary>
|
||||||
|
public class ScheduledReportOptions
|
||||||
|
{
|
||||||
|
// Permissions
|
||||||
|
public bool IncludeInherited { get; set; }
|
||||||
|
public bool ScanFolders { get; set; } = true;
|
||||||
|
|
||||||
|
// Permissions + Storage
|
||||||
|
public int FolderDepth { get; set; } = 1;
|
||||||
|
public bool IncludeSubsites { get; set; }
|
||||||
|
|
||||||
|
// Storage
|
||||||
|
public bool PerLibrary { get; set; } = true;
|
||||||
|
public bool IncludeHiddenLibraries { get; set; } = true;
|
||||||
|
public bool IncludePreservationHold { get; set; } = true;
|
||||||
|
public bool IncludeListAttachments { get; set; } = true;
|
||||||
|
public bool IncludeRecycleBin { get; set; } = true;
|
||||||
|
|
||||||
|
// Duplicates
|
||||||
|
public string DuplicateMode { get; set; } = "Files";
|
||||||
|
public bool MatchSize { get; set; } = true;
|
||||||
|
public bool MatchCreated { get; set; }
|
||||||
|
public bool MatchModified { get; set; }
|
||||||
|
public bool MatchSubfolderCount { get; set; }
|
||||||
|
public bool MatchFileCount { get; set; }
|
||||||
|
public string? Library { get; set; }
|
||||||
|
|
||||||
|
// Version cleanup
|
||||||
|
public List<string> LibraryTitles { get; set; } = new();
|
||||||
|
public int KeepLast { get; set; } = 5;
|
||||||
|
public bool KeepFirst { get; set; }
|
||||||
|
|
||||||
|
// Search
|
||||||
|
public List<string> Extensions { get; set; } = new();
|
||||||
|
public string? Regex { get; set; }
|
||||||
|
public int MaxResults { get; set; } = 1000;
|
||||||
|
|
||||||
|
// User access audit — the logins/emails to report access for (substring matched).
|
||||||
|
public List<string> TargetUserLogins { get; set; } = new();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Optional email delivery for a generated report, sent through Graph (app-only,
|
||||||
|
/// <c>Mail.Send</c>). The report file is attached. Body/subject support the
|
||||||
|
/// placeholders {ReportName}, {ClientName}, {ReportType}, {DateUtc} and {FileName}.
|
||||||
|
/// </summary>
|
||||||
|
public class ReportEmailSettings
|
||||||
|
{
|
||||||
|
public bool Enabled { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Mailbox to send AS (UPN or address). App-only Graph has no signed-in user, so a
|
||||||
|
/// concrete sender mailbox is required — Graph posts to /users/{From}/sendMail.
|
||||||
|
/// </summary>
|
||||||
|
public string From { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
public List<string> To { get; set; } = new();
|
||||||
|
public List<string> Cc { get; set; } = new();
|
||||||
|
|
||||||
|
public string Subject { get; set; } = "{ClientName} — {ReportName}";
|
||||||
|
|
||||||
|
/// <summary>HTML body. Placeholders are substituted before sending.</summary>
|
||||||
|
public string Body { get; set; } =
|
||||||
|
"<p>Hello,</p><p>Please find attached the {ReportType} report \"{ReportName}\" for {ClientName}, generated on {DateUtc} UTC.</p>";
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A user-defined schedule that generates a report for a single client (profile)
|
||||||
|
/// on a recurrence. Persisted to schedules.json.
|
||||||
|
/// </summary>
|
||||||
|
public class ScheduledReport
|
||||||
|
{
|
||||||
|
public string Id { get; set; } = Guid.NewGuid().ToString();
|
||||||
|
|
||||||
|
/// <summary><see cref="TenantProfile.Id"/> this schedule belongs to.</summary>
|
||||||
|
public string ProfileId { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>Human label shown in the UI.</summary>
|
||||||
|
public string Name { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
public ReportType Type { get; set; }
|
||||||
|
|
||||||
|
public ScheduledReportOptions Options { get; set; } = new();
|
||||||
|
|
||||||
|
/// <summary>When true, run against every site in the tenant (site discovery); otherwise use <see cref="SiteUrls"/>.</summary>
|
||||||
|
public bool AllSites { get; set; } = true;
|
||||||
|
|
||||||
|
public List<string> SiteUrls { get; set; } = new();
|
||||||
|
|
||||||
|
public ReportMergeMode MergeMode { get; set; } = ReportMergeMode.SingleMerged;
|
||||||
|
|
||||||
|
public ReportFormat Format { get; set; } = ReportFormat.Html;
|
||||||
|
|
||||||
|
public RecurrenceRule Recurrence { get; set; } = new();
|
||||||
|
|
||||||
|
/// <summary>Optional Graph email delivery of the generated report.</summary>
|
||||||
|
public ReportEmailSettings Email { get; set; } = new();
|
||||||
|
|
||||||
|
public bool Enabled { get; set; } = true;
|
||||||
|
|
||||||
|
public string CreatedBy { get; set; } = string.Empty;
|
||||||
|
public DateTime CreatedUtc { get; set; } = DateTime.UtcNow;
|
||||||
|
|
||||||
|
public DateTime? LastRunUtc { get; set; }
|
||||||
|
public DateTime? NextRunUtc { get; set; }
|
||||||
|
}
|
||||||
@@ -15,4 +15,41 @@ public class TenantProfile
|
|||||||
public string ClientId { get; set; } = string.Empty;
|
public string ClientId { get; set; } = string.Empty;
|
||||||
|
|
||||||
public LogoData? ClientLogo { get; set; }
|
public LogoData? ClientLogo { get; set; }
|
||||||
|
|
||||||
|
// ── Certificate (app-only) credentials ──────────────────────────────────────
|
||||||
|
// Opt-in per client by an admin. When enabled, certificate auth drives BOTH the
|
||||||
|
// interactive session (technicians never sign in to SharePoint per profile) AND
|
||||||
|
// the background report scheduler — all operations run under the app identity.
|
||||||
|
// When disabled, the app falls back to the delegated refresh-token sign-in flow.
|
||||||
|
// SharePoint CSOM app-only requires a certificate (Sites.FullControl.All
|
||||||
|
// application permission, admin-consented). The certificate itself is NOT stored
|
||||||
|
// here — it lives DataProtection-encrypted on disk (see AppOnlyCertStore); this
|
||||||
|
// class only carries the metadata needed to load and display it.
|
||||||
|
|
||||||
|
/// <summary>When true, this client uses certificate (app-only) auth for interactive and scheduled work.</summary>
|
||||||
|
public bool AppOnlyEnabled { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Client (application) ID of the app-registration used for certificate auth. May differ from <see cref="ClientId"/>.</summary>
|
||||||
|
public string AppOnlyClientId { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>Thumbprint of the stored certificate — display/verification only; the key material is stored separately.</summary>
|
||||||
|
public string AppOnlyCertThumbprint { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Clones this profile pointed at a different site/admin URL, preserving every other
|
||||||
|
/// field (notably the certificate metadata) so the auth model is resolved identically
|
||||||
|
/// for the derived URL. Use instead of hand-building partial copies.
|
||||||
|
/// </summary>
|
||||||
|
public TenantProfile CloneForSite(string siteUrl) => new()
|
||||||
|
{
|
||||||
|
Id = Id,
|
||||||
|
Name = Name,
|
||||||
|
TenantUrl = siteUrl,
|
||||||
|
TenantId = TenantId,
|
||||||
|
ClientId = ClientId,
|
||||||
|
ClientLogo = ClientLogo,
|
||||||
|
AppOnlyEnabled = AppOnlyEnabled,
|
||||||
|
AppOnlyClientId = AppOnlyClientId,
|
||||||
|
AppOnlyCertThumbprint = AppOnlyCertThumbprint,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
namespace SharepointToolbox.Web.Core.Models;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Outcome of a user-access audit run. Carries the matched access entries plus per-site
|
||||||
|
/// scan tallies so the UI can report how many sites were skipped for no access or failed
|
||||||
|
/// on a non-access error (a tenant-wide scan continues past both).
|
||||||
|
/// </summary>
|
||||||
|
public record UserAccessAuditResult(
|
||||||
|
IReadOnlyList<UserAccessEntry> Entries,
|
||||||
|
int SitesScanned,
|
||||||
|
int SitesDenied,
|
||||||
|
int SitesFailed);
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
using System.Security.Cryptography.X509Certificates;
|
||||||
|
using Microsoft.AspNetCore.DataProtection;
|
||||||
|
|
||||||
|
namespace SharepointToolbox.Web.Infrastructure.Auth;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// File-backed <see cref="IAppOnlyCertStore"/>. Each profile's certificate is
|
||||||
|
/// re-exported password-less, encrypted with ASP.NET Core Data Protection, and
|
||||||
|
/// written to {certsFolder}/{profileId}.bin. The uploaded PFX password is consumed
|
||||||
|
/// at save time and never persisted.
|
||||||
|
/// </summary>
|
||||||
|
public class AppOnlyCertStore : IAppOnlyCertStore
|
||||||
|
{
|
||||||
|
private const string Purpose = "SharepointToolbox.AppOnlyCert.v1";
|
||||||
|
|
||||||
|
private readonly string _certsFolder;
|
||||||
|
private readonly IDataProtector _protector;
|
||||||
|
|
||||||
|
public AppOnlyCertStore(string certsFolder, IDataProtectionProvider dataProtection)
|
||||||
|
{
|
||||||
|
_certsFolder = certsFolder;
|
||||||
|
_protector = dataProtection.CreateProtector(Purpose);
|
||||||
|
Directory.CreateDirectory(_certsFolder);
|
||||||
|
}
|
||||||
|
|
||||||
|
private string PathFor(string profileId) => Path.Combine(_certsFolder, $"{profileId}.bin");
|
||||||
|
|
||||||
|
public async Task<string> SaveAsync(string profileId, byte[] pfxBytes, string? password, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
// Open the uploaded PFX (Exportable so we can re-emit a password-less copy that
|
||||||
|
// the loader can open later without prompting). EphemeralKeySet keeps the key
|
||||||
|
// out of the Windows certificate store during this transient operation.
|
||||||
|
using var cert = X509CertificateLoader.LoadPkcs12(
|
||||||
|
pfxBytes, password,
|
||||||
|
X509KeyStorageFlags.Exportable | X509KeyStorageFlags.EphemeralKeySet);
|
||||||
|
|
||||||
|
if (!cert.HasPrivateKey)
|
||||||
|
throw new InvalidOperationException("The uploaded certificate has no private key. Export the PFX with its key.");
|
||||||
|
|
||||||
|
var passwordless = cert.Export(X509ContentType.Pkcs12);
|
||||||
|
var protectedBytes = _protector.Protect(passwordless);
|
||||||
|
|
||||||
|
Directory.CreateDirectory(_certsFolder);
|
||||||
|
var tmp = PathFor(profileId) + ".tmp";
|
||||||
|
await File.WriteAllBytesAsync(tmp, protectedBytes, ct);
|
||||||
|
File.Move(tmp, PathFor(profileId), overwrite: true);
|
||||||
|
|
||||||
|
return cert.Thumbprint;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<X509Certificate2?> LoadAsync(string profileId, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var path = PathFor(profileId);
|
||||||
|
if (!File.Exists(path)) return null;
|
||||||
|
|
||||||
|
var protectedBytes = await File.ReadAllBytesAsync(path, ct);
|
||||||
|
var pfx = _protector.Unprotect(protectedBytes);
|
||||||
|
return X509CertificateLoader.LoadPkcs12(
|
||||||
|
pfx, password: null,
|
||||||
|
X509KeyStorageFlags.Exportable | X509KeyStorageFlags.EphemeralKeySet);
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool Exists(string profileId) => File.Exists(PathFor(profileId));
|
||||||
|
|
||||||
|
public void Delete(string profileId)
|
||||||
|
{
|
||||||
|
var path = PathFor(profileId);
|
||||||
|
if (File.Exists(path)) File.Delete(path);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
using Azure.Core;
|
||||||
|
using Azure.Identity;
|
||||||
|
using Microsoft.Graph;
|
||||||
|
using Microsoft.SharePoint.Client;
|
||||||
|
using SharepointToolbox.Web.Core.Models;
|
||||||
|
|
||||||
|
namespace SharepointToolbox.Web.Infrastructure.Auth;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Certificate-based app-only client factory. Acquires tokens with
|
||||||
|
/// <see cref="ClientCertificateCredential"/> and injects the SharePoint bearer token
|
||||||
|
/// through CSOM's <c>ExecutingWebRequest</c> hook — the same mechanism the delegated
|
||||||
|
/// <c>SessionManager</c> uses, so report services see an ordinary authenticated
|
||||||
|
/// <see cref="ClientContext"/> regardless of which auth model produced it.
|
||||||
|
/// </summary>
|
||||||
|
public class AppOnlyContextFactory : IAppOnlyContextFactory
|
||||||
|
{
|
||||||
|
private static readonly string[] GraphScopes = ["https://graph.microsoft.com/.default"];
|
||||||
|
|
||||||
|
private readonly IAppOnlyCertStore _certStore;
|
||||||
|
|
||||||
|
public AppOnlyContextFactory(IAppOnlyCertStore certStore) { _certStore = certStore; }
|
||||||
|
|
||||||
|
public bool IsConfigured(TenantProfile profile) =>
|
||||||
|
profile.AppOnlyEnabled
|
||||||
|
&& !string.IsNullOrWhiteSpace(profile.AppOnlyClientId)
|
||||||
|
&& !string.IsNullOrWhiteSpace(profile.TenantId)
|
||||||
|
&& _certStore.Exists(profile.Id);
|
||||||
|
|
||||||
|
public async Task<AccessToken> GetTokenAsync(TenantProfile profile, string scope, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var credential = await BuildCredentialAsync(profile, ct);
|
||||||
|
return await credential.GetTokenAsync(new TokenRequestContext([scope]), ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<ClientContext> CreateContextAsync(TenantProfile profile, string siteUrl, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var credential = await BuildCredentialAsync(profile, ct);
|
||||||
|
var spScope = SharePointScope(siteUrl);
|
||||||
|
|
||||||
|
var ctx = new ClientContext(siteUrl);
|
||||||
|
ctx.ExecutingWebRequest += (_, e) =>
|
||||||
|
{
|
||||||
|
// CSOM raises this synchronously immediately before sending; acquire the
|
||||||
|
// token synchronously so the header is guaranteed set. ClientCertificateCredential
|
||||||
|
// caches access tokens internally, so this is cheap after the first call.
|
||||||
|
var token = credential.GetToken(new TokenRequestContext([spScope]), CancellationToken.None);
|
||||||
|
e.WebRequestExecutor.RequestHeaders["Authorization"] = "Bearer " + token.Token;
|
||||||
|
};
|
||||||
|
return ctx;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<GraphServiceClient> CreateGraphClientAsync(TenantProfile profile, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var credential = await BuildCredentialAsync(profile, ct);
|
||||||
|
return new GraphServiceClient(credential, GraphScopes);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<string?> TestConnectionAsync(TenantProfile profile, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var ctx = await CreateContextAsync(profile, profile.TenantUrl, ct);
|
||||||
|
ctx.Load(ctx.Web, w => w.Title);
|
||||||
|
await ctx.ExecuteQueryAsync();
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return ex.Message;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<string?> WaitUntilReadyAsync(TenantProfile profile, TimeSpan timeout, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var deadline = DateTimeOffset.UtcNow + timeout;
|
||||||
|
var delay = TimeSpan.FromSeconds(5);
|
||||||
|
string? lastError;
|
||||||
|
do
|
||||||
|
{
|
||||||
|
// Each attempt builds a fresh credential, so a cached 401 never sticks across retries.
|
||||||
|
lastError = await TestConnectionAsync(profile, ct);
|
||||||
|
if (lastError is null) return null;
|
||||||
|
if (DateTimeOffset.UtcNow + delay >= deadline) break;
|
||||||
|
await Task.Delay(delay, ct);
|
||||||
|
}
|
||||||
|
while (DateTimeOffset.UtcNow < deadline);
|
||||||
|
|
||||||
|
return lastError;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<ClientCertificateCredential> BuildCredentialAsync(TenantProfile profile, CancellationToken ct)
|
||||||
|
{
|
||||||
|
if (!profile.AppOnlyEnabled)
|
||||||
|
throw new InvalidOperationException($"App-only reports are not enabled for client '{profile.Name}'.");
|
||||||
|
if (string.IsNullOrWhiteSpace(profile.AppOnlyClientId))
|
||||||
|
throw new InvalidOperationException($"No app-only client ID configured for client '{profile.Name}'.");
|
||||||
|
if (string.IsNullOrWhiteSpace(profile.TenantId))
|
||||||
|
throw new InvalidOperationException($"No tenant ID configured for client '{profile.Name}'.");
|
||||||
|
|
||||||
|
var cert = await _certStore.LoadAsync(profile.Id, ct)
|
||||||
|
?? throw new InvalidOperationException($"No app-only certificate stored for client '{profile.Name}'.");
|
||||||
|
|
||||||
|
var options = new ClientCertificateCredentialOptions
|
||||||
|
{
|
||||||
|
// SharePoint app-only requires the v1 resource audience; SendCertificateChain
|
||||||
|
// improves compatibility with subject-name/issuer-configured app registrations.
|
||||||
|
SendCertificateChain = true
|
||||||
|
};
|
||||||
|
return new ClientCertificateCredential(profile.TenantId, profile.AppOnlyClientId, cert, options);
|
||||||
|
}
|
||||||
|
|
||||||
|
// https://contoso.sharepoint.com/sites/Foo → https://contoso.sharepoint.com/.default
|
||||||
|
private static string SharePointScope(string siteUrl)
|
||||||
|
{
|
||||||
|
if (Uri.TryCreate(siteUrl, UriKind.Absolute, out var uri))
|
||||||
|
return $"{uri.Scheme}://{uri.Host}/.default";
|
||||||
|
return siteUrl.TrimEnd('/') + "/.default";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,22 +5,34 @@ using SharepointToolbox.Web.Services.Session;
|
|||||||
|
|
||||||
namespace SharepointToolbox.Web.Infrastructure.Auth;
|
namespace SharepointToolbox.Web.Infrastructure.Auth;
|
||||||
|
|
||||||
/// <summary>Delegated Graph client using OAuth2 refresh-token flow via ISessionManager.</summary>
|
/// <summary>
|
||||||
|
/// Builds a Graph client for a profile. Certificate-configured profiles get an app-only
|
||||||
|
/// client (no interactive sign-in); all others use the delegated OAuth2 refresh-token flow
|
||||||
|
/// via ISessionManager.
|
||||||
|
/// </summary>
|
||||||
public class GraphClientFactory
|
public class GraphClientFactory
|
||||||
{
|
{
|
||||||
private readonly ISessionCredentialStore _credentialStore;
|
private readonly ISessionCredentialStore _credentialStore;
|
||||||
private readonly ISessionManager _sessionManager;
|
private readonly ISessionManager _sessionManager;
|
||||||
|
private readonly IAppOnlyContextFactory _appOnly;
|
||||||
|
|
||||||
public GraphClientFactory(ISessionCredentialStore credentialStore, ISessionManager sessionManager)
|
public GraphClientFactory(
|
||||||
|
ISessionCredentialStore credentialStore,
|
||||||
|
ISessionManager sessionManager,
|
||||||
|
IAppOnlyContextFactory appOnly)
|
||||||
{
|
{
|
||||||
_credentialStore = credentialStore;
|
_credentialStore = credentialStore;
|
||||||
_sessionManager = sessionManager;
|
_sessionManager = sessionManager;
|
||||||
|
_appOnly = appOnly;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<GraphServiceClient> CreateClientAsync(TenantProfile profile)
|
public async Task<GraphServiceClient> CreateClientAsync(TenantProfile profile)
|
||||||
{
|
{
|
||||||
ArgumentException.ThrowIfNullOrEmpty(profile.TenantId);
|
ArgumentException.ThrowIfNullOrEmpty(profile.TenantId);
|
||||||
|
|
||||||
|
if (_appOnly.IsConfigured(profile))
|
||||||
|
return await _appOnly.CreateGraphClientAsync(profile);
|
||||||
|
|
||||||
var hasTokens = await _credentialStore.HasCredentialsAsync();
|
var hasTokens = await _credentialStore.HasCredentialsAsync();
|
||||||
if (!hasTokens)
|
if (!hasTokens)
|
||||||
throw new InvalidOperationException(
|
throw new InvalidOperationException(
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
using System.Security.Cryptography.X509Certificates;
|
||||||
|
|
||||||
|
namespace SharepointToolbox.Web.Infrastructure.Auth;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Stores the per-client app-only certificate (private key included) encrypted at
|
||||||
|
/// rest, keyed by profile id. Used only by the background scheduler — never exposed
|
||||||
|
/// to the browser.
|
||||||
|
/// </summary>
|
||||||
|
public interface IAppOnlyCertStore
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Persists an uploaded PFX for a profile. Returns the certificate thumbprint.
|
||||||
|
/// The uploaded password is used only to open the PFX; it is not retained.
|
||||||
|
/// </summary>
|
||||||
|
Task<string> SaveAsync(string profileId, byte[] pfxBytes, string? password, CancellationToken ct = default);
|
||||||
|
|
||||||
|
/// <summary>Loads the stored certificate (with private key) for app-only auth, or null if none.</summary>
|
||||||
|
Task<X509Certificate2?> LoadAsync(string profileId, CancellationToken ct = default);
|
||||||
|
|
||||||
|
bool Exists(string profileId);
|
||||||
|
|
||||||
|
void Delete(string profileId);
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
using Azure.Core;
|
||||||
|
using Microsoft.Graph;
|
||||||
|
using Microsoft.SharePoint.Client;
|
||||||
|
using SharepointToolbox.Web.Core.Models;
|
||||||
|
|
||||||
|
namespace SharepointToolbox.Web.Infrastructure.Auth;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Builds app-only (certificate-based) clients for a client profile. Drives BOTH the
|
||||||
|
/// background report scheduler AND the interactive session: when a profile is configured
|
||||||
|
/// for certificate auth (see <see cref="IsConfigured"/>), technicians operate through the
|
||||||
|
/// app identity and never sign in to SharePoint per profile. Requires
|
||||||
|
/// <see cref="TenantProfile.AppOnlyEnabled"/>, an app-only client id, and a stored certificate.
|
||||||
|
/// </summary>
|
||||||
|
public interface IAppOnlyContextFactory
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// True when this profile can authenticate app-only without an interactive sign-in:
|
||||||
|
/// <see cref="TenantProfile.AppOnlyEnabled"/> is set, an app-only client id is present,
|
||||||
|
/// and a certificate is stored for the profile. When false, callers fall back to the
|
||||||
|
/// delegated refresh-token flow.
|
||||||
|
/// </summary>
|
||||||
|
bool IsConfigured(TenantProfile profile);
|
||||||
|
|
||||||
|
/// <summary>CSOM context for a specific site, authenticated app-only.</summary>
|
||||||
|
Task<ClientContext> CreateContextAsync(TenantProfile profile, string siteUrl, CancellationToken ct = default);
|
||||||
|
|
||||||
|
/// <summary>Microsoft Graph client, authenticated app-only.</summary>
|
||||||
|
Task<GraphServiceClient> CreateGraphClientAsync(TenantProfile profile, CancellationToken ct = default);
|
||||||
|
|
||||||
|
/// <summary>Acquires an app-only access token for an arbitrary scope (e.g. a SharePoint host or Graph).</summary>
|
||||||
|
Task<AccessToken> GetTokenAsync(TenantProfile profile, string scope, CancellationToken ct = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Verifies the stored credentials can authenticate against the tenant root web.
|
||||||
|
/// Returns null on success, or an error message describing the failure.
|
||||||
|
/// </summary>
|
||||||
|
Task<string?> TestConnectionAsync(TenantProfile profile, CancellationToken ct = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Polls <see cref="TestConnectionAsync"/> until it succeeds or <paramref name="timeout"/>
|
||||||
|
/// elapses. After a fresh app registration, the certificate key credential and app-role
|
||||||
|
/// admin consent take time to propagate through Entra (token requests 401 until then);
|
||||||
|
/// this waits that window out. Returns null once ready, or the last error on timeout.
|
||||||
|
/// </summary>
|
||||||
|
Task<string?> WaitUntilReadyAsync(TenantProfile profile, TimeSpan timeout, CancellationToken ct = default);
|
||||||
|
}
|
||||||
@@ -7,23 +7,31 @@ using SharepointToolbox.Web.Services.Session;
|
|||||||
namespace SharepointToolbox.Web.Infrastructure.Auth;
|
namespace SharepointToolbox.Web.Infrastructure.Auth;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Delegated session manager using OAuth2 refresh tokens.
|
/// Session manager that resolves the auth model per profile. When a profile is configured
|
||||||
/// Tokens come from ISessionCredentialStore (ProtectedSessionStorage — browser-side only).
|
/// for certificate auth (<see cref="IAppOnlyContextFactory.IsConfigured"/>), contexts are
|
||||||
/// Caches access tokens in-memory per scope for the duration of the Blazor circuit.
|
/// built app-only via the stored certificate and no interactive sign-in is required.
|
||||||
|
/// Otherwise it falls back to the delegated OAuth2 refresh-token flow, whose access tokens
|
||||||
|
/// come from ISessionCredentialStore (ProtectedSessionStorage — browser-side only) and are
|
||||||
|
/// cached in-memory per scope for the duration of the Blazor circuit.
|
||||||
/// Scoped per Blazor circuit.
|
/// Scoped per Blazor circuit.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class SessionManager : ISessionManager
|
public class SessionManager : ISessionManager
|
||||||
{
|
{
|
||||||
private readonly ISessionCredentialStore _credentialStore;
|
private readonly ISessionCredentialStore _credentialStore;
|
||||||
private readonly ITokenRefreshService _tokenRefresh;
|
private readonly ITokenRefreshService _tokenRefresh;
|
||||||
|
private readonly IAppOnlyContextFactory _appOnly;
|
||||||
private readonly Dictionary<string, ClientContext> _contexts = new();
|
private readonly Dictionary<string, ClientContext> _contexts = new();
|
||||||
private readonly Dictionary<string, (string Token, DateTimeOffset ExpiresAt)> _accessTokenCache = new();
|
private readonly Dictionary<string, (string Token, DateTimeOffset ExpiresAt)> _accessTokenCache = new();
|
||||||
private readonly SemaphoreSlim _lock = new(1, 1);
|
private readonly SemaphoreSlim _lock = new(1, 1);
|
||||||
|
|
||||||
public SessionManager(ISessionCredentialStore credentialStore, ITokenRefreshService tokenRefresh)
|
public SessionManager(
|
||||||
|
ISessionCredentialStore credentialStore,
|
||||||
|
ITokenRefreshService tokenRefresh,
|
||||||
|
IAppOnlyContextFactory appOnly)
|
||||||
{
|
{
|
||||||
_credentialStore = credentialStore;
|
_credentialStore = credentialStore;
|
||||||
_tokenRefresh = tokenRefresh;
|
_tokenRefresh = tokenRefresh;
|
||||||
|
_appOnly = appOnly;
|
||||||
}
|
}
|
||||||
|
|
||||||
public bool IsAuthenticated(string tenantUrl) => _contexts.ContainsKey(NormalizeUrl(tenantUrl));
|
public bool IsAuthenticated(string tenantUrl) => _contexts.ContainsKey(NormalizeUrl(tenantUrl));
|
||||||
@@ -62,6 +70,24 @@ public class SessionManager : ISessionManager
|
|||||||
var key = NormalizeUrl(profile.TenantUrl);
|
var key = NormalizeUrl(profile.TenantUrl);
|
||||||
var spScope = NormalizeScopeUrl(profile.TenantUrl) + "/.default";
|
var spScope = NormalizeScopeUrl(profile.TenantUrl) + "/.default";
|
||||||
|
|
||||||
|
// Certificate-configured profiles authenticate app-only: no interactive sign-in,
|
||||||
|
// no session tokens. Build the context through the cert factory and cache it under
|
||||||
|
// the same key so report services see an ordinary authenticated ClientContext.
|
||||||
|
if (_appOnly.IsConfigured(profile))
|
||||||
|
{
|
||||||
|
await _lock.WaitAsync(ct);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (_contexts.TryGetValue(key, out var existingCert))
|
||||||
|
return existingCert;
|
||||||
|
|
||||||
|
var certCtx = await _appOnly.CreateContextAsync(profile, profile.TenantUrl, ct);
|
||||||
|
_contexts[key] = certCtx;
|
||||||
|
return certCtx;
|
||||||
|
}
|
||||||
|
finally { _lock.Release(); }
|
||||||
|
}
|
||||||
|
|
||||||
await _lock.WaitAsync(ct);
|
await _lock.WaitAsync(ct);
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
@@ -90,16 +116,7 @@ public class SessionManager : ISessionManager
|
|||||||
|
|
||||||
public async Task<ClientContext> GetOrCreateContextAsync(string siteUrl, TenantProfile profile, CancellationToken ct = default)
|
public async Task<ClientContext> GetOrCreateContextAsync(string siteUrl, TenantProfile profile, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
var profileForSite = new TenantProfile
|
return await GetOrCreateContextAsync(profile.CloneForSite(siteUrl), ct);
|
||||||
{
|
|
||||||
Id = profile.Id,
|
|
||||||
Name = profile.Name,
|
|
||||||
TenantUrl = siteUrl,
|
|
||||||
TenantId = profile.TenantId,
|
|
||||||
ClientId = profile.ClientId,
|
|
||||||
ClientLogo = profile.ClientLogo,
|
|
||||||
};
|
|
||||||
return await GetOrCreateContextAsync(profileForSite, ct);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task ClearSessionAsync(string tenantUrl)
|
public async Task ClearSessionAsync(string tenantUrl)
|
||||||
|
|||||||
@@ -0,0 +1,87 @@
|
|||||||
|
using System.Text;
|
||||||
|
using System.Text.Json;
|
||||||
|
using SharepointToolbox.Web.Core.Models;
|
||||||
|
|
||||||
|
namespace SharepointToolbox.Web.Infrastructure.Persistence;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// JSON-file index of produced report files (reports-index.json). The files
|
||||||
|
/// themselves live under {ExportsFolder}/{ProfileId}/; this is the catalogue the
|
||||||
|
/// per-client "Reports" list and the id-based download endpoint read.
|
||||||
|
/// </summary>
|
||||||
|
public class GeneratedReportRepository
|
||||||
|
{
|
||||||
|
private readonly string _filePath;
|
||||||
|
private readonly SemaphoreSlim _writeLock = new(1, 1);
|
||||||
|
|
||||||
|
private static readonly JsonSerializerOptions ReadOpts = new() { PropertyNameCaseInsensitive = true };
|
||||||
|
private static readonly JsonSerializerOptions WriteOpts = new()
|
||||||
|
{
|
||||||
|
WriteIndented = true,
|
||||||
|
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
|
||||||
|
};
|
||||||
|
|
||||||
|
public GeneratedReportRepository(string filePath) { _filePath = filePath; }
|
||||||
|
|
||||||
|
public async Task<IReadOnlyList<GeneratedReport>> LoadAsync()
|
||||||
|
{
|
||||||
|
if (!File.Exists(_filePath)) return Array.Empty<GeneratedReport>();
|
||||||
|
string json;
|
||||||
|
try { json = await File.ReadAllTextAsync(_filePath, Encoding.UTF8); }
|
||||||
|
catch (IOException ex) { throw new InvalidDataException($"Failed to read report index: {_filePath}", ex); }
|
||||||
|
|
||||||
|
Root? root;
|
||||||
|
try { root = JsonSerializer.Deserialize<Root>(json, ReadOpts); }
|
||||||
|
catch (JsonException ex) { throw new InvalidDataException($"Invalid JSON in report index: {_filePath}", ex); }
|
||||||
|
|
||||||
|
return (IReadOnlyList<GeneratedReport>?)root?.Reports ?? Array.Empty<GeneratedReport>();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<GeneratedReport?> GetAsync(string id)
|
||||||
|
=> (await LoadAsync()).FirstOrDefault(r => r.Id == id);
|
||||||
|
|
||||||
|
public async Task<IReadOnlyList<GeneratedReport>> LoadForProfileAsync(string profileId)
|
||||||
|
{
|
||||||
|
var all = await LoadAsync();
|
||||||
|
return all.Where(r => r.ProfileId == profileId)
|
||||||
|
.OrderByDescending(r => r.GeneratedUtc)
|
||||||
|
.ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task AddAsync(GeneratedReport report)
|
||||||
|
{
|
||||||
|
await _writeLock.WaitAsync();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var list = (await LoadAsync()).ToList();
|
||||||
|
list.Add(report);
|
||||||
|
await WriteUnlockedAsync(list);
|
||||||
|
}
|
||||||
|
finally { _writeLock.Release(); }
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task DeleteAsync(string id)
|
||||||
|
{
|
||||||
|
await _writeLock.WaitAsync();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var list = (await LoadAsync()).ToList();
|
||||||
|
list.RemoveAll(r => r.Id == id);
|
||||||
|
await WriteUnlockedAsync(list);
|
||||||
|
}
|
||||||
|
finally { _writeLock.Release(); }
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task WriteUnlockedAsync(IReadOnlyList<GeneratedReport> reports)
|
||||||
|
{
|
||||||
|
var json = JsonSerializer.Serialize(new Root { Reports = reports.ToList() }, WriteOpts);
|
||||||
|
var tmpPath = _filePath + ".tmp";
|
||||||
|
var dir = Path.GetDirectoryName(_filePath);
|
||||||
|
if (!string.IsNullOrEmpty(dir)) Directory.CreateDirectory(dir);
|
||||||
|
await File.WriteAllTextAsync(tmpPath, json, Encoding.UTF8);
|
||||||
|
JsonDocument.Parse(await File.ReadAllTextAsync(tmpPath, Encoding.UTF8)).Dispose();
|
||||||
|
File.Move(tmpPath, _filePath, overwrite: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class Root { public List<GeneratedReport> Reports { get; set; } = new(); }
|
||||||
|
}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
using System.Text;
|
||||||
|
using System.Text.Json;
|
||||||
|
using SharepointToolbox.Web.Core.Models;
|
||||||
|
|
||||||
|
namespace SharepointToolbox.Web.Infrastructure.Persistence;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// JSON-file store for <see cref="ScheduledReport"/> definitions (schedules.json).
|
||||||
|
/// Mirrors <see cref="ProfileRepository"/>'s atomic temp-file-then-move write.
|
||||||
|
/// Mutating helpers (<see cref="UpsertAsync"/>/<see cref="DeleteAsync"/>) perform the
|
||||||
|
/// read-modify-write under the same lock so concurrent callers don't clobber each other.
|
||||||
|
/// </summary>
|
||||||
|
public class ScheduledReportRepository
|
||||||
|
{
|
||||||
|
private readonly string _filePath;
|
||||||
|
private readonly SemaphoreSlim _writeLock = new(1, 1);
|
||||||
|
|
||||||
|
private static readonly JsonSerializerOptions ReadOpts = new() { PropertyNameCaseInsensitive = true };
|
||||||
|
private static readonly JsonSerializerOptions WriteOpts = new()
|
||||||
|
{
|
||||||
|
WriteIndented = true,
|
||||||
|
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
|
||||||
|
};
|
||||||
|
|
||||||
|
public ScheduledReportRepository(string filePath) { _filePath = filePath; }
|
||||||
|
|
||||||
|
public async Task<IReadOnlyList<ScheduledReport>> LoadAsync()
|
||||||
|
{
|
||||||
|
if (!File.Exists(_filePath)) return Array.Empty<ScheduledReport>();
|
||||||
|
string json;
|
||||||
|
try { json = await File.ReadAllTextAsync(_filePath, Encoding.UTF8); }
|
||||||
|
catch (IOException ex) { throw new InvalidDataException($"Failed to read schedules: {_filePath}", ex); }
|
||||||
|
|
||||||
|
Root? root;
|
||||||
|
try { root = JsonSerializer.Deserialize<Root>(json, ReadOpts); }
|
||||||
|
catch (JsonException ex) { throw new InvalidDataException($"Invalid JSON in schedules: {_filePath}", ex); }
|
||||||
|
|
||||||
|
return (IReadOnlyList<ScheduledReport>?)root?.Schedules ?? Array.Empty<ScheduledReport>();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<IReadOnlyList<ScheduledReport>> LoadForProfileAsync(string profileId)
|
||||||
|
{
|
||||||
|
var all = await LoadAsync();
|
||||||
|
return all.Where(s => s.ProfileId == profileId).ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task SaveAllAsync(IReadOnlyList<ScheduledReport> schedules)
|
||||||
|
{
|
||||||
|
await _writeLock.WaitAsync();
|
||||||
|
try { await WriteUnlockedAsync(schedules); }
|
||||||
|
finally { _writeLock.Release(); }
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task UpsertAsync(ScheduledReport schedule)
|
||||||
|
{
|
||||||
|
await _writeLock.WaitAsync();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var list = (await LoadAsync()).ToList();
|
||||||
|
var idx = list.FindIndex(s => s.Id == schedule.Id);
|
||||||
|
if (idx >= 0) list[idx] = schedule; else list.Add(schedule);
|
||||||
|
await WriteUnlockedAsync(list);
|
||||||
|
}
|
||||||
|
finally { _writeLock.Release(); }
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task DeleteAsync(string id)
|
||||||
|
{
|
||||||
|
await _writeLock.WaitAsync();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var list = (await LoadAsync()).ToList();
|
||||||
|
list.RemoveAll(s => s.Id == id);
|
||||||
|
await WriteUnlockedAsync(list);
|
||||||
|
}
|
||||||
|
finally { _writeLock.Release(); }
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task WriteUnlockedAsync(IReadOnlyList<ScheduledReport> schedules)
|
||||||
|
{
|
||||||
|
var json = JsonSerializer.Serialize(new Root { Schedules = schedules.ToList() }, WriteOpts);
|
||||||
|
var tmpPath = _filePath + ".tmp";
|
||||||
|
var dir = Path.GetDirectoryName(_filePath);
|
||||||
|
if (!string.IsNullOrEmpty(dir)) Directory.CreateDirectory(dir);
|
||||||
|
await File.WriteAllTextAsync(tmpPath, json, Encoding.UTF8);
|
||||||
|
JsonDocument.Parse(await File.ReadAllTextAsync(tmpPath, Encoding.UTF8)).Dispose();
|
||||||
|
File.Move(tmpPath, _filePath, overwrite: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class Root { public List<ScheduledReport> Schedules { get; set; } = new(); }
|
||||||
|
}
|
||||||
@@ -924,6 +924,33 @@ Cet onglet fait l'inverse : vous sélectionnez un ou plusieurs utilisateurs et i
|
|||||||
<data name="audit.chip.high" xml:space="preserve">
|
<data name="audit.chip.high" xml:space="preserve">
|
||||||
<value>Élevé</value>
|
<value>Élevé</value>
|
||||||
</data>
|
</data>
|
||||||
|
<data name="audit.view.bySite" xml:space="preserve">
|
||||||
|
<value>Par site</value>
|
||||||
|
</data>
|
||||||
|
<data name="audit.view.table" xml:space="preserve">
|
||||||
|
<value>Tableau</value>
|
||||||
|
</data>
|
||||||
|
<data name="audit.bysite.hint" xml:space="preserve">
|
||||||
|
<value>Sites auxquels le(s) utilisateur(s) sélectionné(s) ont accès. Cliquez sur un site pour afficher le détail des permissions.</value>
|
||||||
|
</data>
|
||||||
|
<data name="audit.hint.allSites" xml:space="preserve">
|
||||||
|
<value>Facultatif — laissez vide pour analyser tous les sites du locataire.</value>
|
||||||
|
</data>
|
||||||
|
<data name="audit.status.discoveringSites" xml:space="preserve">
|
||||||
|
<value>Découverte de tous les sites du locataire…</value>
|
||||||
|
</data>
|
||||||
|
<data name="audit.err.discoverFailed" xml:space="preserve">
|
||||||
|
<value>Impossible de lister les sites du locataire : {0}</value>
|
||||||
|
</data>
|
||||||
|
<data name="audit.scan.sitesScanned" xml:space="preserve">
|
||||||
|
<value>{0} site(s) analysé(s)</value>
|
||||||
|
</data>
|
||||||
|
<data name="audit.scan.sitesDenied" xml:space="preserve">
|
||||||
|
<value>{0} ignoré(s) (aucun accès)</value>
|
||||||
|
</data>
|
||||||
|
<data name="audit.scan.sitesFailed" xml:space="preserve">
|
||||||
|
<value>{0} en échec</value>
|
||||||
|
</data>
|
||||||
<data name="audit.chk.includeInherited" xml:space="preserve">
|
<data name="audit.chk.includeInherited" xml:space="preserve">
|
||||||
<value>Inclure les autorisations héritées</value>
|
<value>Inclure les autorisations héritées</value>
|
||||||
</data>
|
</data>
|
||||||
@@ -1326,6 +1353,9 @@ Cet onglet fait l'inverse : vous sélectionnez un ou plusieurs utilisateurs et i
|
|||||||
<data name="nav.reconnect" xml:space="preserve">
|
<data name="nav.reconnect" xml:space="preserve">
|
||||||
<value>Reconnecter</value>
|
<value>Reconnecter</value>
|
||||||
</data>
|
</data>
|
||||||
|
<data name="nav.appIdentity" xml:space="preserve">
|
||||||
|
<value>identité application</value>
|
||||||
|
</data>
|
||||||
<data name="nav.searchPlaceholder" xml:space="preserve">
|
<data name="nav.searchPlaceholder" xml:space="preserve">
|
||||||
<value>Rechercher…</value>
|
<value>Rechercher…</value>
|
||||||
</data>
|
</data>
|
||||||
@@ -1497,6 +1527,12 @@ Cet onglet fait l'inverse : vous sélectionnez un ou plusieurs utilisateurs et i
|
|||||||
<data name="profiles.reg.registered" xml:space="preserve">
|
<data name="profiles.reg.registered" xml:space="preserve">
|
||||||
<value>Application inscrite. Vérifiez et enregistrez le profil.</value>
|
<value>Application inscrite. Vérifiez et enregistrez le profil.</value>
|
||||||
</data>
|
</data>
|
||||||
|
<data name="profiles.reg.propagating" xml:space="preserve">
|
||||||
|
<value>Application inscrite. Propagation du certificat et du consentement en cours…</value>
|
||||||
|
</data>
|
||||||
|
<data name="profiles.reg.notready" xml:space="preserve">
|
||||||
|
<value>Application inscrite, mais l'authentification app-only n'est pas encore prête ({0}). Cela peut prendre quelques minutes ; enregistrez puis utilisez « Tester la connexion » sous peu.</value>
|
||||||
|
</data>
|
||||||
<data name="profiles.reg.requesting" xml:space="preserve">
|
<data name="profiles.reg.requesting" xml:space="preserve">
|
||||||
<value>Demande d'un code de connexion…</value>
|
<value>Demande d'un code de connexion…</value>
|
||||||
</data>
|
</data>
|
||||||
@@ -1887,4 +1923,6 @@ Cet onglet fait l'inverse : vous sélectionnez un ou plusieurs utilisateurs et i
|
|||||||
<data name="help.userType" xml:space="preserve"><value>Membre = un compte interne à votre organisation ; Invité = un utilisateur externe invité d'une autre organisation.</value></data>
|
<data name="help.userType" xml:space="preserve"><value>Membre = un compte interne à votre organisation ; Invité = un utilisateur externe invité d'une autre organisation.</value></data>
|
||||||
<data name="help.templateCapture" xml:space="preserve"><value>La capture enregistre la structure d'un site (bibliothèques, dossiers, groupes de permissions) comme modèle réutilisable pour créer de nouveaux sites.</value></data>
|
<data name="help.templateCapture" xml:space="preserve"><value>La capture enregistre la structure d'un site (bibliothèques, dossiers, groupes de permissions) comme modèle réutilisable pour créer de nouveaux sites.</value></data>
|
||||||
<data name="help.permissionGroups" xml:space="preserve"><value>Les groupes de permissions du site (Propriétaires, Membres, Visiteurs) et leurs membres, afin de recréer la même configuration d'accès.</value></data>
|
<data name="help.permissionGroups" xml:space="preserve"><value>Les groupes de permissions du site (Propriétaires, Membres, Visiteurs) et leurs membres, afin de recréer la même configuration d'accès.</value></data>
|
||||||
|
<data name="nav.scheduledReports" xml:space="preserve"><value>Rapports planifiés</value></data>
|
||||||
|
<data name="nav.reports" xml:space="preserve"><value>Rapports</value></data>
|
||||||
</root>
|
</root>
|
||||||
|
|||||||
@@ -924,6 +924,33 @@ This tab does the reverse: you select one or more users and it finds every objec
|
|||||||
<data name="audit.chip.high" xml:space="preserve">
|
<data name="audit.chip.high" xml:space="preserve">
|
||||||
<value>High</value>
|
<value>High</value>
|
||||||
</data>
|
</data>
|
||||||
|
<data name="audit.view.bySite" xml:space="preserve">
|
||||||
|
<value>By site</value>
|
||||||
|
</data>
|
||||||
|
<data name="audit.view.table" xml:space="preserve">
|
||||||
|
<value>Table</value>
|
||||||
|
</data>
|
||||||
|
<data name="audit.bysite.hint" xml:space="preserve">
|
||||||
|
<value>Sites the selected user(s) can access. Click a site to reveal the permission detail.</value>
|
||||||
|
</data>
|
||||||
|
<data name="audit.hint.allSites" xml:space="preserve">
|
||||||
|
<value>Optional — leave empty to scan every site in the tenant.</value>
|
||||||
|
</data>
|
||||||
|
<data name="audit.status.discoveringSites" xml:space="preserve">
|
||||||
|
<value>Discovering all sites in the tenant…</value>
|
||||||
|
</data>
|
||||||
|
<data name="audit.err.discoverFailed" xml:space="preserve">
|
||||||
|
<value>Could not list tenant sites: {0}</value>
|
||||||
|
</data>
|
||||||
|
<data name="audit.scan.sitesScanned" xml:space="preserve">
|
||||||
|
<value>{0} site(s) scanned</value>
|
||||||
|
</data>
|
||||||
|
<data name="audit.scan.sitesDenied" xml:space="preserve">
|
||||||
|
<value>{0} skipped (no access)</value>
|
||||||
|
</data>
|
||||||
|
<data name="audit.scan.sitesFailed" xml:space="preserve">
|
||||||
|
<value>{0} failed</value>
|
||||||
|
</data>
|
||||||
<data name="audit.chk.includeInherited" xml:space="preserve">
|
<data name="audit.chk.includeInherited" xml:space="preserve">
|
||||||
<value>Include inherited</value>
|
<value>Include inherited</value>
|
||||||
</data>
|
</data>
|
||||||
@@ -1326,6 +1353,9 @@ This tab does the reverse: you select one or more users and it finds every objec
|
|||||||
<data name="nav.reconnect" xml:space="preserve">
|
<data name="nav.reconnect" xml:space="preserve">
|
||||||
<value>Reconnect</value>
|
<value>Reconnect</value>
|
||||||
</data>
|
</data>
|
||||||
|
<data name="nav.appIdentity" xml:space="preserve">
|
||||||
|
<value>app identity</value>
|
||||||
|
</data>
|
||||||
<data name="nav.searchPlaceholder" xml:space="preserve">
|
<data name="nav.searchPlaceholder" xml:space="preserve">
|
||||||
<value>Search…</value>
|
<value>Search…</value>
|
||||||
</data>
|
</data>
|
||||||
@@ -1497,6 +1527,12 @@ This tab does the reverse: you select one or more users and it finds every objec
|
|||||||
<data name="profiles.reg.registered" xml:space="preserve">
|
<data name="profiles.reg.registered" xml:space="preserve">
|
||||||
<value>App registered. Review and Save the profile.</value>
|
<value>App registered. Review and Save the profile.</value>
|
||||||
</data>
|
</data>
|
||||||
|
<data name="profiles.reg.propagating" xml:space="preserve">
|
||||||
|
<value>App registered. Waiting for certificate and consent to propagate…</value>
|
||||||
|
</data>
|
||||||
|
<data name="profiles.reg.notready" xml:space="preserve">
|
||||||
|
<value>App registered, but app-only auth is not ready yet ({0}). It may take a few minutes; Save and use Test connection shortly.</value>
|
||||||
|
</data>
|
||||||
<data name="profiles.reg.requesting" xml:space="preserve">
|
<data name="profiles.reg.requesting" xml:space="preserve">
|
||||||
<value>Requesting a sign-in code…</value>
|
<value>Requesting a sign-in code…</value>
|
||||||
</data>
|
</data>
|
||||||
@@ -1887,4 +1923,6 @@ This tab does the reverse: you select one or more users and it finds every objec
|
|||||||
<data name="help.userType" xml:space="preserve"><value>Member = an account inside your organization; Guest = an external user invited from another organization.</value></data>
|
<data name="help.userType" xml:space="preserve"><value>Member = an account inside your organization; Guest = an external user invited from another organization.</value></data>
|
||||||
<data name="help.templateCapture" xml:space="preserve"><value>Capturing saves a site's structure (libraries, folders, permission groups) as a reusable template you can later apply to create new sites.</value></data>
|
<data name="help.templateCapture" xml:space="preserve"><value>Capturing saves a site's structure (libraries, folders, permission groups) as a reusable template you can later apply to create new sites.</value></data>
|
||||||
<data name="help.permissionGroups" xml:space="preserve"><value>The site's permission groups (Owners, Members, Visitors) and their members, so the same access setup can be recreated.</value></data>
|
<data name="help.permissionGroups" xml:space="preserve"><value>The site's permission groups (Owners, Members, Visitors) and their members, so the same access setup can be recreated.</value></data>
|
||||||
|
<data name="nav.scheduledReports" xml:space="preserve"><value>Scheduled Reports</value></data>
|
||||||
|
<data name="nav.reports" xml:space="preserve"><value>Reports</value></data>
|
||||||
</root>
|
</root>
|
||||||
|
|||||||
+34
@@ -3,6 +3,7 @@ using Microsoft.AspNetCore.Authentication;
|
|||||||
using Microsoft.AspNetCore.Authentication.Cookies;
|
using Microsoft.AspNetCore.Authentication.Cookies;
|
||||||
using Microsoft.AspNetCore.Authentication.OpenIdConnect;
|
using Microsoft.AspNetCore.Authentication.OpenIdConnect;
|
||||||
using Microsoft.AspNetCore.Antiforgery;
|
using Microsoft.AspNetCore.Antiforgery;
|
||||||
|
using Microsoft.AspNetCore.DataProtection;
|
||||||
using Microsoft.AspNetCore.Identity;
|
using Microsoft.AspNetCore.Identity;
|
||||||
using Microsoft.Extensions.Options;
|
using Microsoft.Extensions.Options;
|
||||||
using Microsoft.IdentityModel.Protocols.OpenIdConnect;
|
using Microsoft.IdentityModel.Protocols.OpenIdConnect;
|
||||||
@@ -17,6 +18,7 @@ using SharepointToolbox.Web.Services.Audit;
|
|||||||
using SharepointToolbox.Web.Services.Auth;
|
using SharepointToolbox.Web.Services.Auth;
|
||||||
using SharepointToolbox.Web.Services.Export;
|
using SharepointToolbox.Web.Services.Export;
|
||||||
using SharepointToolbox.Web.Services.OAuth;
|
using SharepointToolbox.Web.Services.OAuth;
|
||||||
|
using SharepointToolbox.Web.Services.Reports;
|
||||||
using SharepointToolbox.Web.Services.Session;
|
using SharepointToolbox.Web.Services.Session;
|
||||||
|
|
||||||
var builder = WebApplication.CreateBuilder(args);
|
var builder = WebApplication.CreateBuilder(args);
|
||||||
@@ -110,11 +112,14 @@ builder.Services.AddHttpClient("oauth");
|
|||||||
builder.Services.Configure<ClientConnectOptions>(builder.Configuration.GetSection("ClientConnect"));
|
builder.Services.Configure<ClientConnectOptions>(builder.Configuration.GetSection("ClientConnect"));
|
||||||
|
|
||||||
// ── App config ────────────────────────────────────────────────────────────────
|
// ── App config ────────────────────────────────────────────────────────────────
|
||||||
|
var certsFolder = Path.Combine(dataFolder, "appcerts");
|
||||||
builder.Services.Configure<AppConfiguration>(opt =>
|
builder.Services.Configure<AppConfiguration>(opt =>
|
||||||
{
|
{
|
||||||
opt.DataFolder = dataFolder;
|
opt.DataFolder = dataFolder;
|
||||||
opt.ExportsFolder = Path.Combine(dataFolder, "exports");
|
opt.ExportsFolder = Path.Combine(dataFolder, "exports");
|
||||||
|
opt.CertsFolder = certsFolder;
|
||||||
Directory.CreateDirectory(opt.ExportsFolder);
|
Directory.CreateDirectory(opt.ExportsFolder);
|
||||||
|
Directory.CreateDirectory(opt.CertsFolder);
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── Persistence (Singleton — files on disk) ───────────────────────────────────
|
// ── Persistence (Singleton — files on disk) ───────────────────────────────────
|
||||||
@@ -123,6 +128,13 @@ builder.Services.AddSingleton(new SettingsRepository(Path.Combine(dataFolder, "s
|
|||||||
builder.Services.AddSingleton(new TemplateRepository(Path.Combine(dataFolder, "templates")));
|
builder.Services.AddSingleton(new TemplateRepository(Path.Combine(dataFolder, "templates")));
|
||||||
builder.Services.AddSingleton(new UserRepository(Path.Combine(dataFolder, "users.json")));
|
builder.Services.AddSingleton(new UserRepository(Path.Combine(dataFolder, "users.json")));
|
||||||
builder.Services.AddSingleton(new AuditRepository(Path.Combine(dataFolder, "audit.jsonl")));
|
builder.Services.AddSingleton(new AuditRepository(Path.Combine(dataFolder, "audit.jsonl")));
|
||||||
|
builder.Services.AddSingleton(new ScheduledReportRepository(Path.Combine(dataFolder, "schedules.json")));
|
||||||
|
builder.Services.AddSingleton(new GeneratedReportRepository(Path.Combine(dataFolder, "reports-index.json")));
|
||||||
|
|
||||||
|
// ── App-only (unattended) auth for scheduled reports ──────────────────────────
|
||||||
|
builder.Services.AddSingleton<IAppOnlyCertStore>(sp =>
|
||||||
|
new AppOnlyCertStore(certsFolder, sp.GetRequiredService<IDataProtectionProvider>()));
|
||||||
|
builder.Services.AddSingleton<IAppOnlyContextFactory, AppOnlyContextFactory>();
|
||||||
|
|
||||||
// ── Auth infrastructure ───────────────────────────────────────────────────────
|
// ── Auth infrastructure ───────────────────────────────────────────────────────
|
||||||
builder.Services.AddSingleton<IPasswordHasher<AppUser>, PasswordHasher<AppUser>>();
|
builder.Services.AddSingleton<IPasswordHasher<AppUser>, PasswordHasher<AppUser>>();
|
||||||
@@ -131,6 +143,7 @@ builder.Services.AddSingleton<IOAuthFlowCache, OAuthFlowCache>();
|
|||||||
builder.Services.AddSingleton<IEntraDeviceCodeFlow, EntraDeviceCodeFlow>();
|
builder.Services.AddSingleton<IEntraDeviceCodeFlow, EntraDeviceCodeFlow>();
|
||||||
builder.Services.AddHttpClient<ITokenRefreshService, TokenRefreshService>();
|
builder.Services.AddHttpClient<ITokenRefreshService, TokenRefreshService>();
|
||||||
builder.Services.AddHttpClient<IAppRegistrationService, AppRegistrationService>();
|
builder.Services.AddHttpClient<IAppRegistrationService, AppRegistrationService>();
|
||||||
|
builder.Services.AddSingleton<ICertProvisioningService, CertProvisioningService>();
|
||||||
builder.Services.AddScoped<GraphClientFactory>();
|
builder.Services.AddScoped<GraphClientFactory>();
|
||||||
|
|
||||||
// ── User session (Scoped = one per Blazor circuit = one per browser tab) ─────
|
// ── User session (Scoped = one per Blazor circuit = one per browser tab) ─────
|
||||||
@@ -177,6 +190,12 @@ builder.Services.AddScoped<VersionCleanupHtmlExportService>();
|
|||||||
builder.Services.AddScoped<BulkResultCsvExportService>();
|
builder.Services.AddScoped<BulkResultCsvExportService>();
|
||||||
builder.Services.AddScoped<WebExportService>();
|
builder.Services.AddScoped<WebExportService>();
|
||||||
|
|
||||||
|
// ── Scheduled reports (background generation) ─────────────────────────────────
|
||||||
|
builder.Services.AddSingleton<ScheduledRunCoordinator>();
|
||||||
|
builder.Services.AddScoped<IReportMailService, ReportMailService>();
|
||||||
|
builder.Services.AddScoped<IScheduledReportRunner, ScheduledReportRunner>();
|
||||||
|
builder.Services.AddHostedService<ScheduledReportHostedService>();
|
||||||
|
|
||||||
var app = builder.Build();
|
var app = builder.Build();
|
||||||
|
|
||||||
if (!app.Environment.IsDevelopment())
|
if (!app.Environment.IsDevelopment())
|
||||||
@@ -301,6 +320,21 @@ app.MapGet("/export/download/{fileName}", async (string fileName, IOptions<AppCo
|
|||||||
return Results.File(bytes, ct, fileName);
|
return Results.File(bytes, ct, fileName);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── Scheduled report download (id-based, scoped to the client's exports subfolder) ──
|
||||||
|
app.MapGet("/reports/download/{id}", async (string id, GeneratedReportRepository index, IOptions<AppConfiguration> opts, HttpContext ctx) =>
|
||||||
|
{
|
||||||
|
if (!ctx.User.Identity?.IsAuthenticated ?? true) return Results.Unauthorized();
|
||||||
|
var report = await index.GetAsync(id);
|
||||||
|
if (report is null || report.Status != ReportRunStatus.Success || string.IsNullOrEmpty(report.FileName))
|
||||||
|
return Results.NotFound();
|
||||||
|
// ProfileId and FileName are app-generated; GetFileName strips any traversal just in case.
|
||||||
|
var path = Path.Combine(opts.Value.ExportsFolder, report.ProfileId, Path.GetFileName(report.FileName));
|
||||||
|
if (!File.Exists(path)) return Results.NotFound();
|
||||||
|
var bytes = await File.ReadAllBytesAsync(path);
|
||||||
|
var mime = string.IsNullOrEmpty(report.Mime) ? "application/octet-stream" : report.Mime;
|
||||||
|
return Results.File(bytes, mime, report.FileName);
|
||||||
|
});
|
||||||
|
|
||||||
// ── Audit CSV download ────────────────────────────────────────────────────────
|
// ── Audit CSV download ────────────────────────────────────────────────────────
|
||||||
app.MapGet("/audit/export", async (AuditRepository auditRepo, HttpContext ctx) =>
|
app.MapGet("/audit/export", async (AuditRepository auditRepo, HttpContext ctx) =>
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -24,6 +24,22 @@ public class AppRegistrationService : IAppRegistrationService
|
|||||||
"AllSites.FullControl", // CSOM — site permissions, content, admin operations
|
"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;
|
private readonly HttpClient _http;
|
||||||
|
|
||||||
public AppRegistrationService(HttpClient http) { _http = http; }
|
public AppRegistrationService(HttpClient http) { _http = http; }
|
||||||
@@ -32,103 +48,145 @@ public class AppRegistrationService : IAppRegistrationService
|
|||||||
string adminAccessToken,
|
string adminAccessToken,
|
||||||
string tenantName,
|
string tenantName,
|
||||||
string redirectUri,
|
string redirectUri,
|
||||||
|
CertProvisioningResult? appOnlyCert = null,
|
||||||
CancellationToken ct = default)
|
CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
_http.DefaultRequestHeaders.Authorization =
|
_http.DefaultRequestHeaders.Authorization =
|
||||||
new AuthenticationHeaderValue("Bearer", adminAccessToken);
|
new AuthenticationHeaderValue("Bearer", adminAccessToken);
|
||||||
|
|
||||||
// 1. Resolve Graph + SharePoint service principals in the target tenant
|
bool wantsAppOnly = appOnlyCert is not null;
|
||||||
var (graphSpId, graphPermIds) = await ResolveServicePrincipalAsync(GraphAppId, GraphScopes, ct);
|
|
||||||
var (spSpId, spPermIds) = await ResolveServicePrincipalAsync(SharePointAppId, SpScopes, ct);
|
|
||||||
|
|
||||||
// 2. Create app registration
|
// 1. Resolve Graph + SharePoint service principals + the permission ids we need
|
||||||
var appBody = new
|
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}",
|
["displayName"] = $"SP Toolbox — {tenantName}",
|
||||||
signInAudience = "AzureADMyOrg",
|
["signInAudience"] = "AzureADMyOrg",
|
||||||
isFallbackPublicClient = true,
|
["isFallbackPublicClient"] = true,
|
||||||
// Register the redirect under the PUBLIC client platform so the connect
|
// Register the redirect under the PUBLIC client platform so the connect
|
||||||
// flow can redeem the auth code with PKCE only (no client secret). A
|
// 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
|
// redirect under `web` makes Entra treat the app as confidential and the
|
||||||
// token exchange fails with AADSTS7000218 (secret required).
|
// token exchange fails with AADSTS7000218 (secret required).
|
||||||
publicClient = new { redirectUris = new[] { redirectUri } },
|
["publicClient"] = new { redirectUris = new[] { redirectUri } },
|
||||||
requiredResourceAccess = new[]
|
["requiredResourceAccess"] = new[]
|
||||||
{
|
{
|
||||||
new
|
new
|
||||||
{
|
{
|
||||||
resourceAppId = GraphAppId,
|
resourceAppId = GraphAppId,
|
||||||
resourceAccess = graphPermIds.Select(id => new { id, type = "Scope" }).ToArray(),
|
resourceAccess = ResourceAccess(graph.ScopeIds, wantsAppOnly ? graph.AppRoleIds : []),
|
||||||
},
|
},
|
||||||
new
|
new
|
||||||
{
|
{
|
||||||
resourceAppId = SharePointAppId,
|
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",
|
// Attach the certificate as a sign-in credential so app-only token requests succeed.
|
||||||
appBody, ct);
|
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()!;
|
var clientId = appJson.GetProperty("appId").GetString()!;
|
||||||
|
|
||||||
// 3. Create service principal for the new app
|
// 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);
|
new { appId = clientId }, ct);
|
||||||
var newSpId = spJson.GetProperty("id").GetString()!;
|
var newSpId = spJson.GetProperty("id").GetString()!;
|
||||||
|
|
||||||
// 4. Grant org-wide admin consent for Graph
|
// 4. Grant org-wide admin consent for Graph + SharePoint delegated scopes
|
||||||
await PostGraphAsync("https://graph.microsoft.com/v1.0/oauth2PermissionGrants",
|
await GrantDelegatedConsentAsync(newSpId, graph.SpObjectId, GraphScopes, ct);
|
||||||
new
|
await GrantDelegatedConsentAsync(newSpId, sp.SpObjectId, SpScopes, ct);
|
||||||
{
|
|
||||||
clientId = newSpId,
|
|
||||||
consentType = "AllPrincipals",
|
|
||||||
resourceId = graphSpId,
|
|
||||||
scope = string.Join(" ", GraphScopes),
|
|
||||||
}, ct);
|
|
||||||
|
|
||||||
// 5. Grant org-wide admin consent for SharePoint
|
// 5. Grant admin consent for application permissions (app roles) when app-only
|
||||||
await PostGraphAsync("https://graph.microsoft.com/v1.0/oauth2PermissionGrants",
|
if (wantsAppOnly)
|
||||||
new
|
{
|
||||||
{
|
await GrantAppRolesAsync(newSpId, graph.SpObjectId, graph.AppRoleIds, ct);
|
||||||
clientId = newSpId,
|
await GrantAppRolesAsync(newSpId, sp.SpObjectId, sp.AppRoleIds, ct);
|
||||||
consentType = "AllPrincipals",
|
}
|
||||||
resourceId = spSpId,
|
|
||||||
scope = string.Join(" ", SpScopes),
|
|
||||||
}, ct);
|
|
||||||
|
|
||||||
return clientId;
|
return clientId;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Returns (servicePrincipalObjectId, [permissionIds matching requested scopes])
|
private static object BuildKeyCredential(CertProvisioningResult cert, string tenantName) => new
|
||||||
private async Task<(string SpObjectId, string[] PermissionIds)> ResolveServicePrincipalAsync(
|
{
|
||||||
string appId, string[] scopeNames, CancellationToken ct)
|
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" +
|
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 resp = await _http.GetAsync(url, ct);
|
||||||
var json = await resp.Content.ReadAsStringAsync(ct);
|
var json = await resp.Content.ReadAsStringAsync(ct);
|
||||||
resp.EnsureSuccessStatusCode();
|
resp.EnsureSuccessStatusCode();
|
||||||
|
|
||||||
using var doc = JsonDocument.Parse(json);
|
using var doc = JsonDocument.Parse(json);
|
||||||
var values = doc.RootElement.GetProperty("value");
|
var sp = doc.RootElement.GetProperty("value").EnumerateArray().First();
|
||||||
var sp = values.EnumerateArray().First();
|
var spId = sp.GetProperty("id").GetString()!;
|
||||||
var spId = sp.GetProperty("id").GetString()!;
|
|
||||||
var allScopes = sp.GetProperty("oauth2PermissionScopes");
|
|
||||||
|
|
||||||
|
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>();
|
var ids = new List<string>();
|
||||||
foreach (var scope in allScopes.EnumerateArray())
|
foreach (var entry in entries.EnumerateArray())
|
||||||
{
|
{
|
||||||
var value = scope.GetProperty("value").GetString();
|
var value = entry.GetProperty("value").GetString();
|
||||||
if (scopeNames.Contains(value, StringComparer.OrdinalIgnoreCase))
|
if (wantedValues.Contains(value, StringComparer.OrdinalIgnoreCase))
|
||||||
ids.Add(scope.GetProperty("id").GetString()!);
|
ids.Add(entry.GetProperty("id").GetString()!);
|
||||||
}
|
}
|
||||||
|
return ids.ToArray();
|
||||||
return (spId, ids.ToArray());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task<JsonElement> PostGraphAsync(string url, object body, CancellationToken ct)
|
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 }),
|
JsonSerializer.Serialize(body, new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase }),
|
||||||
Encoding.UTF8,
|
Encoding.UTF8,
|
||||||
"application/json");
|
"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>
|
/// <summary>
|
||||||
/// Creates an Entra ID app registration in the target tenant using a delegated admin token
|
/// Creates an Entra ID app registration in the target tenant using a delegated admin token
|
||||||
/// (requires Application.ReadWrite.All + DelegatedPermissionGrant.ReadWrite.All scope).
|
/// (requires Application.ReadWrite.All + DelegatedPermissionGrant.ReadWrite.All +
|
||||||
/// Grants org-wide admin consent for SharePoint + Graph delegated permissions.
|
/// AppRoleAssignment.ReadWrite.All scope). Grants org-wide admin consent for SharePoint + Graph
|
||||||
/// Returns the new app's client ID (appId).
|
/// 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>
|
/// </summary>
|
||||||
Task<string> CreateAsync(
|
Task<string> CreateAsync(
|
||||||
string adminAccessToken,
|
string adminAccessToken,
|
||||||
string tenantName,
|
string tenantName,
|
||||||
string redirectUri,
|
string redirectUri,
|
||||||
|
CertProvisioningResult? appOnlyCert = null,
|
||||||
CancellationToken ct = default);
|
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);
|
||||||
|
}
|
||||||
@@ -4,7 +4,7 @@ namespace SharepointToolbox.Web.Services;
|
|||||||
|
|
||||||
public interface IUserAccessAuditService
|
public interface IUserAccessAuditService
|
||||||
{
|
{
|
||||||
Task<IReadOnlyList<UserAccessEntry>> AuditUsersAsync(
|
Task<UserAccessAuditResult> AuditUsersAsync(
|
||||||
ISessionManager sessionManager,
|
ISessionManager sessionManager,
|
||||||
TenantProfile currentProfile,
|
TenantProfile currentProfile,
|
||||||
IReadOnlyList<string> targetUserLogins,
|
IReadOnlyList<string> targetUserLogins,
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
using SharepointToolbox.Web.Core.Models;
|
||||||
|
|
||||||
|
namespace SharepointToolbox.Web.Services.Reports;
|
||||||
|
|
||||||
|
/// <summary>Sends a generated report as a Graph email (app-only, Mail.Send).</summary>
|
||||||
|
public interface IReportMailService
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Sends <paramref name="bytes"/> as an attachment to the recipients in
|
||||||
|
/// <paramref name="settings"/>, sending AS <see cref="ReportEmailSettings.From"/>.
|
||||||
|
/// Subject/body placeholders are resolved from the schedule and client.
|
||||||
|
/// </summary>
|
||||||
|
Task SendAsync(
|
||||||
|
TenantProfile profile,
|
||||||
|
ScheduledReport schedule,
|
||||||
|
ReportEmailSettings settings,
|
||||||
|
string fileName,
|
||||||
|
string mime,
|
||||||
|
byte[] bytes,
|
||||||
|
CancellationToken ct = default);
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
using SharepointToolbox.Web.Core.Models;
|
||||||
|
|
||||||
|
namespace SharepointToolbox.Web.Services.Reports;
|
||||||
|
|
||||||
|
public interface IScheduledReportRunner
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Generates one report for the given schedule using app-only auth, writes the
|
||||||
|
/// file under the client's exports subfolder, records it in the report index, and
|
||||||
|
/// audit-logs the run. Never throws for report-level failures — a failed run is
|
||||||
|
/// captured as a <see cref="GeneratedReport"/> with <see cref="ReportRunStatus.Failed"/>.
|
||||||
|
/// </summary>
|
||||||
|
Task<GeneratedReport> RunAsync(ScheduledReport schedule, CancellationToken ct = default);
|
||||||
|
}
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
using Microsoft.Graph.Models;
|
||||||
|
using Microsoft.Graph.Users.Item.SendMail;
|
||||||
|
using SharepointToolbox.Web.Core.Models;
|
||||||
|
using SharepointToolbox.Web.Infrastructure.Auth;
|
||||||
|
|
||||||
|
namespace SharepointToolbox.Web.Services.Reports;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Delivers a generated report by email through Graph. Uses the client's app-only
|
||||||
|
/// (certificate) Graph client, which has no signed-in user, so it posts to
|
||||||
|
/// <c>/users/{From}/sendMail</c> — the configured sender mailbox must exist in the
|
||||||
|
/// tenant and the app registration must hold the <c>Mail.Send</c> application role.
|
||||||
|
/// </summary>
|
||||||
|
public class ReportMailService : IReportMailService
|
||||||
|
{
|
||||||
|
// Graph caps a single sendMail request at ~4 MB total; larger files need an upload
|
||||||
|
// session we don't implement. Reject early with a clear message instead of a 413.
|
||||||
|
private const long MaxAttachmentBytes = 3 * 1024 * 1024;
|
||||||
|
|
||||||
|
private readonly IAppOnlyContextFactory _appOnly;
|
||||||
|
|
||||||
|
public ReportMailService(IAppOnlyContextFactory appOnly) { _appOnly = appOnly; }
|
||||||
|
|
||||||
|
public async Task SendAsync(
|
||||||
|
TenantProfile profile,
|
||||||
|
ScheduledReport schedule,
|
||||||
|
ReportEmailSettings settings,
|
||||||
|
string fileName,
|
||||||
|
string mime,
|
||||||
|
byte[] bytes,
|
||||||
|
CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(settings.From))
|
||||||
|
throw new InvalidOperationException("No sender mailbox (From) configured for report email.");
|
||||||
|
|
||||||
|
var to = CleanAddresses(settings.To);
|
||||||
|
var cc = CleanAddresses(settings.Cc);
|
||||||
|
if (to.Count == 0 && cc.Count == 0)
|
||||||
|
throw new InvalidOperationException("Report email has no To or Cc recipients.");
|
||||||
|
|
||||||
|
var message = new Message
|
||||||
|
{
|
||||||
|
Subject = Substitute(settings.Subject, profile, schedule, fileName),
|
||||||
|
Body = new ItemBody
|
||||||
|
{
|
||||||
|
ContentType = BodyType.Html,
|
||||||
|
Content = Substitute(settings.Body, profile, schedule, fileName)
|
||||||
|
},
|
||||||
|
ToRecipients = to.Select(Recipient).ToList(),
|
||||||
|
CcRecipients = cc.Select(Recipient).ToList(),
|
||||||
|
};
|
||||||
|
|
||||||
|
if (bytes.LongLength > MaxAttachmentBytes)
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
$"Report '{fileName}' is {bytes.LongLength / 1024.0 / 1024.0:F1} MB — too large to email " +
|
||||||
|
$"(Graph sendMail limit ~{MaxAttachmentBytes / 1024 / 1024} MB).");
|
||||||
|
|
||||||
|
message.Attachments = new List<Attachment>
|
||||||
|
{
|
||||||
|
new FileAttachment
|
||||||
|
{
|
||||||
|
OdataType = "#microsoft.graph.fileAttachment",
|
||||||
|
Name = fileName,
|
||||||
|
ContentType = mime,
|
||||||
|
ContentBytes = bytes,
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
var graph = await _appOnly.CreateGraphClientAsync(profile, ct);
|
||||||
|
await graph.Users[settings.From].SendMail.PostAsync(
|
||||||
|
new SendMailPostRequestBody { Message = message, SaveToSentItems = false }, cancellationToken: ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<string> CleanAddresses(IEnumerable<string> raw) =>
|
||||||
|
raw.Select(a => a?.Trim() ?? string.Empty)
|
||||||
|
.Where(a => a.Length > 0)
|
||||||
|
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
private static Recipient Recipient(string address) =>
|
||||||
|
new() { EmailAddress = new EmailAddress { Address = address } };
|
||||||
|
|
||||||
|
private static string Substitute(string template, TenantProfile profile, ScheduledReport schedule, string fileName) =>
|
||||||
|
(template ?? string.Empty)
|
||||||
|
.Replace("{ReportName}", schedule.Name)
|
||||||
|
.Replace("{ClientName}", profile.Name)
|
||||||
|
.Replace("{ReportType}", schedule.Type.ToString())
|
||||||
|
.Replace("{FileName}", fileName)
|
||||||
|
.Replace("{DateUtc}", DateTime.UtcNow.ToString("yyyy-MM-dd HH:mm"));
|
||||||
|
}
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using SharepointToolbox.Web.Core.Models;
|
||||||
|
using SharepointToolbox.Web.Infrastructure.Persistence;
|
||||||
|
|
||||||
|
namespace SharepointToolbox.Web.Services.Reports;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Background scheduler. Every <see cref="TickInterval"/> it loads the schedule
|
||||||
|
/// definitions, runs any whose <see cref="ScheduledReport.NextRunUtc"/> is due, and
|
||||||
|
/// advances their next-run stamp. Each run executes in its own DI scope (report
|
||||||
|
/// services are scoped). Due schedules run sequentially within a tick to bound the
|
||||||
|
/// load a single tenant sees; a long run simply delays the next due check.
|
||||||
|
/// </summary>
|
||||||
|
public class ScheduledReportHostedService : BackgroundService
|
||||||
|
{
|
||||||
|
private static readonly TimeSpan TickInterval = TimeSpan.FromMinutes(1);
|
||||||
|
|
||||||
|
private readonly IServiceScopeFactory _scopeFactory;
|
||||||
|
private readonly ScheduledReportRepository _repo;
|
||||||
|
private readonly ScheduledRunCoordinator _coordinator;
|
||||||
|
private readonly ILogger<ScheduledReportHostedService> _log;
|
||||||
|
|
||||||
|
public ScheduledReportHostedService(
|
||||||
|
IServiceScopeFactory scopeFactory,
|
||||||
|
ScheduledReportRepository repo,
|
||||||
|
ScheduledRunCoordinator coordinator,
|
||||||
|
ILogger<ScheduledReportHostedService> log)
|
||||||
|
{
|
||||||
|
_scopeFactory = scopeFactory;
|
||||||
|
_repo = repo;
|
||||||
|
_coordinator = coordinator;
|
||||||
|
_log = log;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||||
|
{
|
||||||
|
_log.LogInformation("Scheduled report service started (tick {Interval}).", TickInterval);
|
||||||
|
using var timer = new PeriodicTimer(TickInterval);
|
||||||
|
|
||||||
|
// Tick once at startup, then on every interval.
|
||||||
|
do
|
||||||
|
{
|
||||||
|
try { await TickAsync(stoppingToken); }
|
||||||
|
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) { break; }
|
||||||
|
catch (Exception ex) { _log.LogError(ex, "Scheduled report tick failed."); }
|
||||||
|
}
|
||||||
|
while (await WaitAsync(timer, stoppingToken));
|
||||||
|
|
||||||
|
_log.LogInformation("Scheduled report service stopping.");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<bool> WaitAsync(PeriodicTimer timer, CancellationToken ct)
|
||||||
|
{
|
||||||
|
try { return await timer.WaitForNextTickAsync(ct); }
|
||||||
|
catch (OperationCanceledException) { return false; }
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task TickAsync(CancellationToken ct)
|
||||||
|
{
|
||||||
|
// Global pause: an admin has suspended all cadence-triggered runs. In-flight
|
||||||
|
// runs are unaffected (those are stopped individually); due schedules simply
|
||||||
|
// wait — NextRun is not advanced, so they fire once resumed.
|
||||||
|
if (_coordinator.IsPaused) return;
|
||||||
|
|
||||||
|
var now = DateTime.UtcNow;
|
||||||
|
var schedules = await _repo.LoadAsync();
|
||||||
|
|
||||||
|
foreach (var schedule in schedules)
|
||||||
|
{
|
||||||
|
ct.ThrowIfCancellationRequested();
|
||||||
|
if (!schedule.Enabled) continue;
|
||||||
|
|
||||||
|
// First time we see an enabled schedule with no next-run: arm it, don't run.
|
||||||
|
if (schedule.NextRunUtc is null)
|
||||||
|
{
|
||||||
|
schedule.NextRunUtc = schedule.Recurrence.ComputeNextRunUtc(now);
|
||||||
|
await _repo.UpsertAsync(schedule);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (schedule.NextRunUtc > now) continue;
|
||||||
|
|
||||||
|
await RunOneAsync(schedule, now, ct);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task RunOneAsync(ScheduledReport schedule, DateTime now, CancellationToken ct)
|
||||||
|
{
|
||||||
|
// Register the run so the UI can stop it mid-flight. The returned token trips on
|
||||||
|
// either app shutdown (ct) or an admin Stop. Null = a run is already in progress
|
||||||
|
// (e.g. a long previous run or a "Run now"); skip without advancing so it retries.
|
||||||
|
var token = _coordinator.TryBegin(schedule.Id, ct);
|
||||||
|
if (token is null)
|
||||||
|
{
|
||||||
|
_log.LogWarning("Schedule '{Name}' ({Id}) still running; skipping this tick.", schedule.Name, schedule.Id);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var scope = _scopeFactory.CreateScope();
|
||||||
|
var runner = scope.ServiceProvider.GetRequiredService<IScheduledReportRunner>();
|
||||||
|
await runner.RunAsync(schedule, token.Value);
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException) when (ct.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
throw; // app shutdown — bubble up to stop the service loop
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException)
|
||||||
|
{
|
||||||
|
// Stopped via the coordinator (admin Stop / Stop all), not shutdown. Not a failure.
|
||||||
|
_log.LogInformation("Schedule '{Name}' ({Id}) was stopped.", schedule.Name, schedule.Id);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
// RunAsync already captures report-level failures; this guards anything
|
||||||
|
// thrown outside it so one bad schedule can't stop the others advancing.
|
||||||
|
_log.LogError(ex, "Schedule '{Name}' ({Id}) failed to run.", schedule.Name, schedule.Id);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_coordinator.Complete(schedule.Id);
|
||||||
|
// Advance regardless of outcome so a persistently failing (or stopped)
|
||||||
|
// schedule doesn't hot-loop every tick.
|
||||||
|
schedule.LastRunUtc = now;
|
||||||
|
schedule.NextRunUtc = schedule.Recurrence.ComputeNextRunUtc(now);
|
||||||
|
await _repo.UpsertAsync(schedule);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,297 @@
|
|||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
using Microsoft.SharePoint.Client;
|
||||||
|
using SharepointToolbox.Web.Core.Helpers;
|
||||||
|
using SharepointToolbox.Web.Core.Models;
|
||||||
|
using SharepointToolbox.Web.Infrastructure.Auth;
|
||||||
|
using SharepointToolbox.Web.Infrastructure.Persistence;
|
||||||
|
using SharepointToolbox.Web.Services.Export;
|
||||||
|
using AppConfiguration = SharepointToolbox.Web.Core.Models.AppConfiguration;
|
||||||
|
|
||||||
|
namespace SharepointToolbox.Web.Services.Reports;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Drives one scheduled report end-to-end under app-only auth: resolve the client
|
||||||
|
/// profile, discover/select sites, scan each site with the matching report service,
|
||||||
|
/// merge the per-site results into a single artifact, write it to the client's
|
||||||
|
/// exports subfolder, and index + audit the run.
|
||||||
|
///
|
||||||
|
/// Report services take a plain <see cref="ClientContext"/>, so they are reused
|
||||||
|
/// verbatim; the only difference from the interactive pages is that the context is
|
||||||
|
/// produced by <see cref="IAppOnlyContextFactory"/> instead of the delegated session.
|
||||||
|
/// </summary>
|
||||||
|
public class ScheduledReportRunner : IScheduledReportRunner
|
||||||
|
{
|
||||||
|
private readonly ProfileRepository _profiles;
|
||||||
|
private readonly SettingsRepository _settings;
|
||||||
|
private readonly GeneratedReportRepository _index;
|
||||||
|
private readonly AuditRepository _audit;
|
||||||
|
private readonly IAppOnlyContextFactory _appOnly;
|
||||||
|
private readonly IReportMailService _mail;
|
||||||
|
private readonly AppConfiguration _cfg;
|
||||||
|
private readonly ILogger<ScheduledReportRunner> _log;
|
||||||
|
|
||||||
|
private readonly IPermissionsService _perm;
|
||||||
|
private readonly ISharePointGroupResolver _groupResolver;
|
||||||
|
private readonly IStorageService _storage;
|
||||||
|
private readonly IDuplicatesService _dup;
|
||||||
|
private readonly ISearchService _search;
|
||||||
|
private readonly IVersionCleanupService _version;
|
||||||
|
|
||||||
|
private readonly CsvExportService _permCsv;
|
||||||
|
private readonly HtmlExportService _permHtml;
|
||||||
|
private readonly StorageCsvExportService _storageCsv;
|
||||||
|
private readonly StorageHtmlExportService _storageHtml;
|
||||||
|
private readonly DuplicatesCsvExportService _dupCsv;
|
||||||
|
private readonly DuplicatesHtmlExportService _dupHtml;
|
||||||
|
private readonly SearchCsvExportService _searchCsv;
|
||||||
|
private readonly SearchHtmlExportService _searchHtml;
|
||||||
|
private readonly UserAccessCsvExportService _uaCsv;
|
||||||
|
private readonly UserAccessHtmlExportService _uaHtml;
|
||||||
|
private readonly VersionCleanupHtmlExportService _versionHtml;
|
||||||
|
|
||||||
|
public ScheduledReportRunner(
|
||||||
|
ProfileRepository profiles, SettingsRepository settings,
|
||||||
|
GeneratedReportRepository index, AuditRepository audit,
|
||||||
|
IAppOnlyContextFactory appOnly, IReportMailService mail, IOptions<AppConfiguration> cfg,
|
||||||
|
ILogger<ScheduledReportRunner> log,
|
||||||
|
IPermissionsService perm, ISharePointGroupResolver groupResolver,
|
||||||
|
IStorageService storage, IDuplicatesService dup,
|
||||||
|
ISearchService search, IVersionCleanupService version,
|
||||||
|
CsvExportService permCsv, HtmlExportService permHtml,
|
||||||
|
StorageCsvExportService storageCsv, StorageHtmlExportService storageHtml,
|
||||||
|
DuplicatesCsvExportService dupCsv, DuplicatesHtmlExportService dupHtml,
|
||||||
|
SearchCsvExportService searchCsv, SearchHtmlExportService searchHtml,
|
||||||
|
UserAccessCsvExportService uaCsv, UserAccessHtmlExportService uaHtml,
|
||||||
|
VersionCleanupHtmlExportService versionHtml)
|
||||||
|
{
|
||||||
|
_profiles = profiles; _settings = settings; _index = index; _audit = audit;
|
||||||
|
_appOnly = appOnly; _mail = mail; _cfg = cfg.Value; _log = log;
|
||||||
|
_perm = perm; _groupResolver = groupResolver; _storage = storage; _dup = dup; _search = search; _version = version;
|
||||||
|
_permCsv = permCsv; _permHtml = permHtml;
|
||||||
|
_storageCsv = storageCsv; _storageHtml = storageHtml;
|
||||||
|
_dupCsv = dupCsv; _dupHtml = dupHtml;
|
||||||
|
_searchCsv = searchCsv; _searchHtml = searchHtml;
|
||||||
|
_uaCsv = uaCsv; _uaHtml = uaHtml; _versionHtml = versionHtml;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<GeneratedReport> RunAsync(ScheduledReport schedule, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var profile = (await _profiles.LoadAsync()).FirstOrDefault(p => p.Id == schedule.ProfileId);
|
||||||
|
|
||||||
|
if (profile is null)
|
||||||
|
return await FailAsync(schedule, profileName: "(unknown)", $"Client profile '{schedule.ProfileId}' not found.");
|
||||||
|
if (!profile.AppOnlyEnabled)
|
||||||
|
return await FailAsync(schedule, profile.Name, $"App-only reports are not enabled for client '{profile.Name}'.");
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var sites = await ResolveSitesAsync(profile, schedule, ct);
|
||||||
|
if (sites.Count == 0)
|
||||||
|
return await FailAsync(schedule, profile.Name, "No sites resolved for this schedule.");
|
||||||
|
|
||||||
|
var settings = await _settings.LoadAsync();
|
||||||
|
var branding = new ReportBranding(settings.MspLogo, profile.ClientLogo);
|
||||||
|
var ts = DateTime.UtcNow.ToString("yyyyMMdd_HHmmss");
|
||||||
|
|
||||||
|
var output = await GenerateAsync(profile, schedule, sites, branding, ts, ct);
|
||||||
|
|
||||||
|
var dir = Path.Combine(_cfg.ExportsFolder, profile.Id);
|
||||||
|
Directory.CreateDirectory(dir);
|
||||||
|
var path = Path.Combine(dir, output.FileName);
|
||||||
|
await System.IO.File.WriteAllBytesAsync(path, output.Bytes, ct);
|
||||||
|
|
||||||
|
var report = new GeneratedReport
|
||||||
|
{
|
||||||
|
ProfileId = profile.Id,
|
||||||
|
ScheduledReportId = schedule.Id,
|
||||||
|
Type = schedule.Type,
|
||||||
|
Name = schedule.Name,
|
||||||
|
FileName = output.FileName,
|
||||||
|
Mime = output.Mime,
|
||||||
|
SizeBytes = output.Bytes.LongLength,
|
||||||
|
Status = ReportRunStatus.Success
|
||||||
|
};
|
||||||
|
|
||||||
|
// Optional email delivery. A delivery failure does NOT fail the report —
|
||||||
|
// the file is already on disk and indexed; we record the error on the entry.
|
||||||
|
string mailNote = "";
|
||||||
|
if (schedule.Email.Enabled)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await _mail.SendAsync(profile, schedule, schedule.Email,
|
||||||
|
output.FileName, output.Mime, output.Bytes, ct);
|
||||||
|
report.Emailed = true;
|
||||||
|
mailNote = $", emailed to {schedule.Email.To.Count + schedule.Email.Cc.Count} recipient(s)";
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException) { throw; }
|
||||||
|
catch (Exception mex)
|
||||||
|
{
|
||||||
|
report.EmailError = mex.Message;
|
||||||
|
mailNote = $", email FAILED: {mex.Message}";
|
||||||
|
_log.LogError(mex, "Emailing report '{Name}' for '{Client}' failed", schedule.Name, profile.Name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await _index.AddAsync(report);
|
||||||
|
await AuditAsync(profile.Name, schedule, ReportRunStatus.Success,
|
||||||
|
$"{schedule.Type} report '{output.FileName}' ({sites.Count} site(s), {output.Bytes.LongLength / 1024.0:F1} KB){mailNote}");
|
||||||
|
|
||||||
|
_log.LogInformation("Scheduled report '{Name}' for '{Client}' produced {File}",
|
||||||
|
schedule.Name, profile.Name, output.FileName);
|
||||||
|
return report;
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException)
|
||||||
|
{
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_log.LogError(ex, "Scheduled report '{Name}' for '{Client}' failed", schedule.Name, profile.Name);
|
||||||
|
return await FailAsync(schedule, profile.Name, ex.Message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<IReadOnlyList<SiteInfo>> ResolveSitesAsync(
|
||||||
|
TenantProfile profile, ScheduledReport schedule, CancellationToken ct)
|
||||||
|
{
|
||||||
|
if (!schedule.AllSites)
|
||||||
|
return schedule.SiteUrls
|
||||||
|
.Where(u => !string.IsNullOrWhiteSpace(u))
|
||||||
|
.Select(u => new SiteInfo(u, ReportSplitHelper.DeriveSiteLabel(u)))
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
using var adminCtx = await _appOnly.CreateContextAsync(
|
||||||
|
profile, TenantSiteEnumerator.BuildAdminUrl(profile.TenantUrl), ct);
|
||||||
|
return await TenantSiteEnumerator.EnumerateAsync(adminCtx, ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<MergeOutput> GenerateAsync(
|
||||||
|
TenantProfile profile, ScheduledReport schedule, IReadOnlyList<SiteInfo> sites,
|
||||||
|
ReportBranding branding, string ts, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var o = schedule.Options;
|
||||||
|
var progress = new Progress<OperationProgress>();
|
||||||
|
var mode = schedule.MergeMode;
|
||||||
|
var fmt = schedule.Format;
|
||||||
|
|
||||||
|
// Runs the same per-site scan loop for every report type. ClientContext is
|
||||||
|
// app-only and disposed per site.
|
||||||
|
async Task<List<(string Label, IReadOnlyList<T> Results)>> ScanSites<T>(
|
||||||
|
Func<ClientContext, SiteInfo, Task<IReadOnlyList<T>>> scan)
|
||||||
|
{
|
||||||
|
var bySite = new List<(string, IReadOnlyList<T>)>();
|
||||||
|
foreach (var site in sites)
|
||||||
|
{
|
||||||
|
ct.ThrowIfCancellationRequested();
|
||||||
|
using var ctx = await _appOnly.CreateContextAsync(profile, site.Url, ct);
|
||||||
|
bySite.Add((site.Title, await scan(ctx, site)));
|
||||||
|
}
|
||||||
|
return bySite;
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (schedule.Type)
|
||||||
|
{
|
||||||
|
case ReportType.Permissions:
|
||||||
|
{
|
||||||
|
var opts = new ScanOptions(o.IncludeInherited, o.ScanFolders, o.FolderDepth, o.IncludeSubsites);
|
||||||
|
var bySite = await ScanSites<PermissionEntry>((ctx, _) => _perm.ScanSiteAsync(ctx, opts, progress, ct));
|
||||||
|
return ReportMergeHelper.Build(bySite, mode, "permissions", ts, fmt,
|
||||||
|
fmt == ReportFormat.Csv ? rs => _permCsv.BuildCsv(rs) : rs => _permHtml.BuildHtml(rs, branding));
|
||||||
|
}
|
||||||
|
|
||||||
|
case ReportType.Storage:
|
||||||
|
{
|
||||||
|
var opts = new StorageScanOptions(o.PerLibrary, o.IncludeSubsites, o.FolderDepth,
|
||||||
|
o.IncludeHiddenLibraries, o.IncludePreservationHold, o.IncludeListAttachments, o.IncludeRecycleBin);
|
||||||
|
var bySite = await ScanSites<StorageNode>((ctx, _) => _storage.CollectStorageAsync(ctx, opts, progress, ct));
|
||||||
|
return ReportMergeHelper.Build(bySite, mode, "storage", ts, fmt,
|
||||||
|
fmt == ReportFormat.Csv ? rs => _storageCsv.BuildCsv(rs) : rs => _storageHtml.BuildHtml(rs, branding));
|
||||||
|
}
|
||||||
|
|
||||||
|
case ReportType.Duplicates:
|
||||||
|
{
|
||||||
|
var opts = new DuplicateScanOptions(o.DuplicateMode, o.MatchSize, o.MatchCreated, o.MatchModified,
|
||||||
|
o.MatchSubfolderCount, o.MatchFileCount, o.IncludeSubsites, o.Library);
|
||||||
|
var bySite = await ScanSites<DuplicateGroup>((ctx, _) => _dup.ScanDuplicatesAsync(ctx, opts, progress, ct));
|
||||||
|
return ReportMergeHelper.Build(bySite, mode, "duplicates", ts, fmt,
|
||||||
|
fmt == ReportFormat.Csv ? rs => _dupCsv.BuildCsv(rs) : rs => _dupHtml.BuildHtml(rs, branding));
|
||||||
|
}
|
||||||
|
|
||||||
|
case ReportType.Search:
|
||||||
|
{
|
||||||
|
var bySite = await ScanSites<SearchResult>((ctx, site) =>
|
||||||
|
{
|
||||||
|
var opts = new SearchOptions(o.Extensions.ToArray(), o.Regex,
|
||||||
|
null, null, null, null, null, null, o.Library, o.MaxResults, site.Url);
|
||||||
|
return _search.SearchFilesAsync(ctx, opts, progress, ct);
|
||||||
|
});
|
||||||
|
return ReportMergeHelper.Build(bySite, mode, "search", ts, fmt,
|
||||||
|
fmt == ReportFormat.Csv ? rs => _searchCsv.BuildCsv(rs) : rs => _searchHtml.BuildHtml(rs, branding));
|
||||||
|
}
|
||||||
|
|
||||||
|
case ReportType.UserAccess:
|
||||||
|
{
|
||||||
|
var opts = new ScanOptions(o.IncludeInherited, o.ScanFolders, o.FolderDepth, o.IncludeSubsites);
|
||||||
|
var targets = o.TargetUserLogins
|
||||||
|
.Select(l => l.Trim().ToLowerInvariant())
|
||||||
|
.Where(l => l.Length > 0).ToHashSet();
|
||||||
|
var bySite = await ScanSites<UserAccessEntry>(async (ctx, site) =>
|
||||||
|
{
|
||||||
|
var permEntries = await _perm.ScanSiteAsync(ctx, opts, progress, ct);
|
||||||
|
// Expand SharePoint group membership so group-granted access is attributed to
|
||||||
|
// the target user (otherwise the scan only sees the group principal, not the user).
|
||||||
|
var groupMembers = await UserAccessAuditService.ResolveGroupMembersAsync(
|
||||||
|
_groupResolver, ctx, profile, permEntries, ct);
|
||||||
|
return UserAccessAuditService.TransformEntries(permEntries, targets, site, groupMembers).ToList();
|
||||||
|
});
|
||||||
|
return ReportMergeHelper.Build(bySite, mode, "user_audit", ts, fmt,
|
||||||
|
fmt == ReportFormat.Csv
|
||||||
|
? rs => _uaCsv.BuildCsv(rs.FirstOrDefault()?.UserDisplayName ?? "Users", rs.FirstOrDefault()?.UserLogin ?? "", rs)
|
||||||
|
: rs => _uaHtml.BuildHtml(rs, mergePermissions: false, branding: branding));
|
||||||
|
}
|
||||||
|
|
||||||
|
case ReportType.VersionCleanup:
|
||||||
|
{
|
||||||
|
// Destructive: this DELETES old file versions. No CSV exporter exists, so the
|
||||||
|
// output is always the HTML summary of what was removed.
|
||||||
|
var opts = new VersionCleanupOptions(o.LibraryTitles, o.KeepLast, o.KeepFirst);
|
||||||
|
var bySite = await ScanSites<VersionCleanupResult>((ctx, _) => _version.DeleteOldVersionsAsync(ctx, opts, progress, ct));
|
||||||
|
return ReportMergeHelper.Build(bySite, mode, "versions", ts, ReportFormat.Html,
|
||||||
|
rs => _versionHtml.BuildHtml(rs, branding));
|
||||||
|
}
|
||||||
|
|
||||||
|
default:
|
||||||
|
throw new NotSupportedException($"Report type {schedule.Type} is not supported.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<GeneratedReport> FailAsync(ScheduledReport schedule, string profileName, string error)
|
||||||
|
{
|
||||||
|
var report = new GeneratedReport
|
||||||
|
{
|
||||||
|
ProfileId = schedule.ProfileId,
|
||||||
|
ScheduledReportId = schedule.Id,
|
||||||
|
Type = schedule.Type,
|
||||||
|
Name = schedule.Name,
|
||||||
|
Status = ReportRunStatus.Failed,
|
||||||
|
Error = error
|
||||||
|
};
|
||||||
|
await _index.AddAsync(report);
|
||||||
|
await AuditAsync(profileName, schedule, ReportRunStatus.Failed, error);
|
||||||
|
return report;
|
||||||
|
}
|
||||||
|
|
||||||
|
private Task AuditAsync(string profileName, ScheduledReport schedule, ReportRunStatus status, string details)
|
||||||
|
=> _audit.AppendAsync(new AuditEntry
|
||||||
|
{
|
||||||
|
Action = "ScheduledReport",
|
||||||
|
ClientName = profileName,
|
||||||
|
Sites = new List<string>(),
|
||||||
|
Details = $"{status}: {schedule.Name} — {details}",
|
||||||
|
UserEmail = "system",
|
||||||
|
UserDisplay = "Scheduler",
|
||||||
|
UserRole = UserRole.Admin
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
using System.Collections.Concurrent;
|
||||||
|
|
||||||
|
namespace SharepointToolbox.Web.Services.Reports;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Process-wide coordinator for scheduled-report execution. Covers two operator needs:
|
||||||
|
/// • <b>Cancel an in-flight run</b> — every active run registers a linked
|
||||||
|
/// <see cref="CancellationTokenSource"/> keyed by schedule id, so the UI (or a
|
||||||
|
/// global stop) can abort a report that is currently executing, whether it was
|
||||||
|
/// started by the scheduler or by "Run now".
|
||||||
|
/// • <b>Pause future runs</b> — a global flag the background scheduler honours,
|
||||||
|
/// letting an admin suspend all cadence-triggered runs at once without toggling
|
||||||
|
/// each schedule's <c>Enabled</c> flag.
|
||||||
|
///
|
||||||
|
/// In-memory and singleton. The pause flag does NOT survive a process restart (a
|
||||||
|
/// restart resumes the scheduler); per-schedule <c>Enabled</c> flags persist and are
|
||||||
|
/// the durable way to keep a schedule off.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class ScheduledRunCoordinator
|
||||||
|
{
|
||||||
|
private readonly ConcurrentDictionary<string, CancellationTokenSource> _active = new();
|
||||||
|
private volatile bool _paused;
|
||||||
|
|
||||||
|
/// <summary>True while the scheduler is globally paused (no schedules fire).</summary>
|
||||||
|
public bool IsPaused => _paused;
|
||||||
|
|
||||||
|
public void Pause() => _paused = true;
|
||||||
|
public void Resume() => _paused = false;
|
||||||
|
|
||||||
|
/// <summary>True while a run is registered for this schedule id.</summary>
|
||||||
|
public bool IsRunning(string scheduleId) => _active.ContainsKey(scheduleId);
|
||||||
|
|
||||||
|
/// <summary>Snapshot of schedule ids with a run in progress.</summary>
|
||||||
|
public IReadOnlyCollection<string> RunningIds => _active.Keys.ToArray();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Registers a run for <paramref name="scheduleId"/> and returns a token that trips
|
||||||
|
/// when either the caller's <paramref name="linked"/> token (e.g. app shutdown) or a
|
||||||
|
/// <see cref="Cancel"/>/<see cref="CancelAll"/> call fires. Returns <c>null</c> if a
|
||||||
|
/// run is already registered for this schedule — callers must skip to avoid overlap.
|
||||||
|
/// Always pair a non-null return with <see cref="Complete"/> in a <c>finally</c>.
|
||||||
|
/// </summary>
|
||||||
|
public CancellationToken? TryBegin(string scheduleId, CancellationToken linked)
|
||||||
|
{
|
||||||
|
var cts = CancellationTokenSource.CreateLinkedTokenSource(linked);
|
||||||
|
if (!_active.TryAdd(scheduleId, cts)) { cts.Dispose(); return null; }
|
||||||
|
return cts.Token;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Deregisters and disposes the run for this schedule id.</summary>
|
||||||
|
public void Complete(string scheduleId)
|
||||||
|
{
|
||||||
|
if (_active.TryRemove(scheduleId, out var cts)) cts.Dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Signals cancellation to the run for this schedule id. Returns false if none.</summary>
|
||||||
|
public bool Cancel(string scheduleId)
|
||||||
|
{
|
||||||
|
if (_active.TryGetValue(scheduleId, out var cts))
|
||||||
|
{
|
||||||
|
try { cts.Cancel(); return true; }
|
||||||
|
catch (ObjectDisposedException) { return false; } // completed between lookup and cancel
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Signals cancellation to every run in progress. Returns the count signalled.</summary>
|
||||||
|
public int CancelAll()
|
||||||
|
{
|
||||||
|
int n = 0;
|
||||||
|
foreach (var cts in _active.Values)
|
||||||
|
{
|
||||||
|
try { cts.Cancel(); n++; }
|
||||||
|
catch (ObjectDisposedException) { /* completed concurrently */ }
|
||||||
|
}
|
||||||
|
return n;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -63,7 +63,11 @@ public class SharePointGroupResolver : ISharePointGroupResolver
|
|||||||
{
|
{
|
||||||
graphClient ??= await _graphClientFactory.CreateClientAsync(profile);
|
graphClient ??= await _graphClientFactory.CreateClientAsync(profile);
|
||||||
var aadId = ExtractAadGroupId(user.LoginName);
|
var aadId = ExtractAadGroupId(user.LoginName);
|
||||||
var leafUsers = await ResolveAadGroupAsync(graphClient, aadId, ct);
|
// M365 (group-connected) sites add the group's OWNERS claim ("…_o") to the
|
||||||
|
// site Owners SP group; resolve owners for those, transitive members otherwise.
|
||||||
|
var leafUsers = IsM365GroupOwnersClaim(user.LoginName)
|
||||||
|
? await ResolveAadGroupOwnersAsync(graphClient, aadId, ct)
|
||||||
|
: await ResolveAadGroupAsync(graphClient, aadId, ct);
|
||||||
members.AddRange(leafUsers);
|
members.AddRange(leafUsers);
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
@@ -83,10 +87,27 @@ public class SharePointGroupResolver : ISharePointGroupResolver
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Group principals that must be expanded via Graph:
|
||||||
|
// c:0t.c|tenant|<guid> → AAD security group
|
||||||
|
// c:0o.c|federateddirectoryclaimprovider|<guid> → M365 group members (group-connected/Teams sites)
|
||||||
|
// c:0o.c|federateddirectoryclaimprovider|<guid>_o → M365 group owners
|
||||||
|
// The M365 cases are how modern group-connected sites grant access; without expanding them a
|
||||||
|
// user who is "just a member of the site" never appears in a user-centric audit.
|
||||||
internal static bool IsAadGroup(string login) =>
|
internal static bool IsAadGroup(string login) =>
|
||||||
login.StartsWith("c:0t.c|", StringComparison.OrdinalIgnoreCase);
|
login.StartsWith("c:0t.c|", StringComparison.OrdinalIgnoreCase) ||
|
||||||
|
login.StartsWith("c:0o.c|", StringComparison.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
internal static bool IsM365GroupOwnersClaim(string login) =>
|
||||||
|
login.StartsWith("c:0o.c|", StringComparison.OrdinalIgnoreCase) &&
|
||||||
|
login.EndsWith("_o", StringComparison.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
// Last claim segment is the group GUID; M365 owners claims append "_o" — strip it.
|
||||||
|
internal static string ExtractAadGroupId(string login)
|
||||||
|
{
|
||||||
|
var id = login[(login.LastIndexOf('|') + 1)..];
|
||||||
|
return id.EndsWith("_o", StringComparison.OrdinalIgnoreCase) ? id[..^2] : id;
|
||||||
|
}
|
||||||
|
|
||||||
internal static string ExtractAadGroupId(string login) => login[(login.LastIndexOf('|') + 1)..];
|
|
||||||
internal static string StripClaims(string login) => login[(login.LastIndexOf('|') + 1)..];
|
internal static string StripClaims(string login) => login[(login.LastIndexOf('|') + 1)..];
|
||||||
|
|
||||||
private static async Task<IEnumerable<ResolvedMember>> ResolveAadGroupAsync(
|
private static async Task<IEnumerable<ResolvedMember>> ResolveAadGroupAsync(
|
||||||
@@ -122,4 +143,40 @@ public class SharePointGroupResolver : ISharePointGroupResolver
|
|||||||
return Enumerable.Empty<ResolvedMember>();
|
return Enumerable.Empty<ResolvedMember>();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// M365 group owners (the "…_o" claim). Owners are a direct, non-nested collection, so no
|
||||||
|
// transitive expansion is needed — owners cannot themselves be groups.
|
||||||
|
private static async Task<IEnumerable<ResolvedMember>> ResolveAadGroupOwnersAsync(
|
||||||
|
GraphServiceClient graphClient, string aadGroupId, CancellationToken ct)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var response = await graphClient.Groups[aadGroupId].Owners.GraphUser.GetAsync(config =>
|
||||||
|
{
|
||||||
|
config.QueryParameters.Select = new[] { "displayName", "userPrincipalName" };
|
||||||
|
config.QueryParameters.Top = 999;
|
||||||
|
}, ct);
|
||||||
|
if (response?.Value is null) return Enumerable.Empty<ResolvedMember>();
|
||||||
|
|
||||||
|
var owners = new List<ResolvedMember>();
|
||||||
|
var iter = PageIterator<GraphUser, GraphUserCollectionResponse>.CreatePageIterator(
|
||||||
|
graphClient, response,
|
||||||
|
user =>
|
||||||
|
{
|
||||||
|
if (ct.IsCancellationRequested) return false;
|
||||||
|
owners.Add(new ResolvedMember(
|
||||||
|
user.DisplayName ?? user.UserPrincipalName ?? "Unknown",
|
||||||
|
user.UserPrincipalName ?? string.Empty));
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
await iter.IterateAsync(ct);
|
||||||
|
return owners;
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException) { throw; }
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Log.Warning("Could not resolve AAD group '{Id}' owners: {Error}", aadGroupId, ex.Message);
|
||||||
|
return Enumerable.Empty<ResolvedMember>();
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,31 +1,38 @@
|
|||||||
using Microsoft.Online.SharePoint.TenantAdministration;
|
|
||||||
using Microsoft.SharePoint.Client;
|
|
||||||
using SharepointToolbox.Web.Core.Helpers;
|
using SharepointToolbox.Web.Core.Helpers;
|
||||||
using SharepointToolbox.Web.Core.Models;
|
using SharepointToolbox.Web.Core.Models;
|
||||||
|
using SharepointToolbox.Web.Infrastructure.Auth;
|
||||||
using SharepointToolbox.Web.Services.Session;
|
using SharepointToolbox.Web.Services.Session;
|
||||||
|
|
||||||
namespace SharepointToolbox.Web.Services;
|
namespace SharepointToolbox.Web.Services;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Delegated CSOM implementation of <see cref="ISiteDiscoveryService"/>.
|
/// Enumerates every site collection in a tenant via the SharePoint tenant-admin endpoint
|
||||||
|
/// (<c>Tenant.GetSitePropertiesFromSharePointByFilters</c>), paging through all results.
|
||||||
|
/// The auth model only changes how the admin-host context is built:
|
||||||
///
|
///
|
||||||
/// Enumerates every site collection via the SharePoint tenant admin endpoint
|
/// • Certificate (app-only) profiles build the admin context through the cert factory — the
|
||||||
/// (<c>Tenant.GetSitePropertiesFromSharePointByFilters</c>), paging through all
|
/// same path the background report scheduler uses (<see cref="Services.Reports"/>), which
|
||||||
/// results. Requires the signed-in user to be a SharePoint administrator.
|
/// relies only on the SharePoint <c>Sites.FullControl.All</c> application permission the cert
|
||||||
|
/// app already holds. (The earlier Graph <c>/sites/getAllSites</c> path was dropped: it needs
|
||||||
|
/// a separate Graph <c>Sites.Read.All</c> grant the cert app is not provisioned with, so it
|
||||||
|
/// returned empty/403 and tenant-wide audits silently fell back to the root site alone.)
|
||||||
|
/// • Delegated profiles build the admin context through the session manager; this requires the
|
||||||
|
/// signed-in user to be a SharePoint administrator.
|
||||||
///
|
///
|
||||||
/// The Graph <c>/sites?search=*</c> endpoint was deliberately abandoned: it ranks
|
/// The Graph <c>/sites?search=*</c> endpoint was deliberately abandoned for both: it ranks by
|
||||||
/// by relevance and is capped server-side, so it silently dropped sites and
|
/// relevance and is capped server-side, silently dropping sites and returning varying counts.
|
||||||
/// returned varying counts run-to-run. <c>/sites/getAllSites</c> is app-only and
|
|
||||||
/// 403s on a delegated user token. The tenant admin enumeration is the only path
|
|
||||||
/// that returns the complete, stable set under the app's delegated auth model.
|
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class SiteDiscoveryService : ISiteDiscoveryService
|
public class SiteDiscoveryService : ISiteDiscoveryService
|
||||||
{
|
{
|
||||||
private readonly ISessionManager _sessionManager;
|
private readonly ISessionManager _sessionManager;
|
||||||
|
private readonly IAppOnlyContextFactory _appOnly;
|
||||||
|
|
||||||
public SiteDiscoveryService(ISessionManager sessionManager)
|
public SiteDiscoveryService(
|
||||||
|
ISessionManager sessionManager,
|
||||||
|
IAppOnlyContextFactory appOnly)
|
||||||
{
|
{
|
||||||
_sessionManager = sessionManager;
|
_sessionManager = sessionManager;
|
||||||
|
_appOnly = appOnly;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<IReadOnlyList<SiteInfo>> SearchSitesAsync(
|
public async Task<IReadOnlyList<SiteInfo>> SearchSitesAsync(
|
||||||
@@ -35,103 +42,19 @@ public class SiteDiscoveryService : ISiteDiscoveryService
|
|||||||
{
|
{
|
||||||
ArgumentException.ThrowIfNullOrEmpty(profile.TenantUrl);
|
ArgumentException.ThrowIfNullOrEmpty(profile.TenantUrl);
|
||||||
|
|
||||||
// Site enumeration only exists on the tenant admin endpoint.
|
var adminUrl = TenantSiteEnumerator.BuildAdminUrl(profile.TenantUrl);
|
||||||
var adminProfile = new TenantProfile
|
|
||||||
|
// App-only profiles: build the admin-host context through the cert factory (matches the
|
||||||
|
// scheduler), enumerating under the SharePoint app permission the cert already grants.
|
||||||
|
if (_appOnly.IsConfigured(profile))
|
||||||
{
|
{
|
||||||
Id = profile.Id,
|
using var adminCtx = await _appOnly.CreateContextAsync(profile, adminUrl, ct);
|
||||||
Name = profile.Name,
|
return await TenantSiteEnumerator.EnumerateAsync(adminCtx, ct);
|
||||||
TenantUrl = BuildAdminUrl(profile.TenantUrl),
|
|
||||||
TenantId = profile.TenantId,
|
|
||||||
ClientId = profile.ClientId,
|
|
||||||
ClientLogo = profile.ClientLogo,
|
|
||||||
};
|
|
||||||
|
|
||||||
var ctx = await _sessionManager.GetOrCreateContextAsync(adminProfile, ct);
|
|
||||||
var tenant = new Tenant(ctx);
|
|
||||||
|
|
||||||
var filter = new SPOSitePropertiesEnumerableFilter
|
|
||||||
{
|
|
||||||
IncludeDetail = false,
|
|
||||||
IncludePersonalSite = PersonalSiteFilter.Exclude,
|
|
||||||
StartIndex = null,
|
|
||||||
Template = null,
|
|
||||||
};
|
|
||||||
|
|
||||||
var results = new List<SiteInfo>();
|
|
||||||
SPOSitePropertiesEnumerable page;
|
|
||||||
do
|
|
||||||
{
|
|
||||||
ct.ThrowIfCancellationRequested();
|
|
||||||
|
|
||||||
page = await FetchPageWithColdTokenRetryAsync(ctx, tenant, filter, ct);
|
|
||||||
|
|
||||||
foreach (var sp in page)
|
|
||||||
{
|
|
||||||
var url = sp.Url ?? string.Empty;
|
|
||||||
if (string.IsNullOrEmpty(url)) continue;
|
|
||||||
// Belt-and-braces: PersonalSiteFilter.Exclude already drops OneDrive.
|
|
||||||
if (url.Contains("/personal/", StringComparison.OrdinalIgnoreCase)) continue;
|
|
||||||
var title = string.IsNullOrEmpty(sp.Title) ? url : sp.Title;
|
|
||||||
results.Add(new SiteInfo(url, title));
|
|
||||||
}
|
|
||||||
|
|
||||||
// NextStartIndexFromSharePoint is empty/null once the last page is returned.
|
|
||||||
filter.StartIndex = page.NextStartIndexFromSharePoint;
|
|
||||||
}
|
}
|
||||||
while (!string.IsNullOrEmpty(filter.StartIndex));
|
|
||||||
|
|
||||||
return results
|
// Delegated profiles: enumeration only exists on the tenant admin endpoint.
|
||||||
.GroupBy(s => s.Url, StringComparer.OrdinalIgnoreCase)
|
var adminProfile = profile.CloneForSite(adminUrl);
|
||||||
.Select(g => g.First())
|
var ctx = await _sessionManager.GetOrCreateContextAsync(adminProfile, ct);
|
||||||
.OrderBy(s => s.Title, StringComparer.OrdinalIgnoreCase)
|
return await TenantSiteEnumerator.EnumerateAsync(ctx, ct);
|
||||||
.ToList();
|
|
||||||
}
|
|
||||||
|
|
||||||
private const int MaxColdTokenAttempts = 4;
|
|
||||||
private const int BackoffBaseSeconds = 3;
|
|
||||||
|
|
||||||
// The tenant admin endpoint transiently 403s on a cold delegated token (the same
|
|
||||||
// behaviour the elevation coordinator handles): the first call against the admin
|
|
||||||
// host can be denied while the token warms, then clears within seconds. Retry the
|
|
||||||
// admin query on access-denied with backoff. A genuine lack of SharePoint tenant
|
|
||||||
// administrator rights keeps failing and surfaces the enriched 403 after retries —
|
|
||||||
// elevation cannot self-grant tenant-admin, so there is nothing to auto-correct.
|
|
||||||
//
|
|
||||||
// The request (GetSiteProperties + Load) MUST be re-issued inside the loop: a failed
|
|
||||||
// CSOM ExecuteQuery clears the context's pending-request queue, so retrying the
|
|
||||||
// execute alone would run an empty batch, leave the page uninitialized, and throw
|
|
||||||
// "The collection has not been initialized" on iteration.
|
|
||||||
private static async Task<SPOSitePropertiesEnumerable> FetchPageWithColdTokenRetryAsync(
|
|
||||||
ClientContext ctx, Tenant tenant, SPOSitePropertiesEnumerableFilter filter, CancellationToken ct)
|
|
||||||
{
|
|
||||||
for (int attempt = 1; ; attempt++)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var page = tenant.GetSitePropertiesFromSharePointByFilters(filter);
|
|
||||||
ctx.Load(page);
|
|
||||||
await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(ctx, null, ct);
|
|
||||||
return page;
|
|
||||||
}
|
|
||||||
catch (SharePointAccessDeniedException ex) when (attempt < MaxColdTokenAttempts)
|
|
||||||
{
|
|
||||||
var delay = TimeSpan.FromSeconds(BackoffBaseSeconds * attempt);
|
|
||||||
Serilog.Log.Warning(
|
|
||||||
"Tenant admin endpoint denied during site discovery (attempt {N}/{Max}); " +
|
|
||||||
"retrying in {Delay}s. {Err}",
|
|
||||||
attempt, MaxColdTokenAttempts, delay.TotalSeconds, ex.Message);
|
|
||||||
await Task.Delay(delay, ct);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// https://contoso.sharepoint.com[/sites/Foo] → https://contoso-admin.sharepoint.com
|
|
||||||
private static string BuildAdminUrl(string tenantUrl)
|
|
||||||
{
|
|
||||||
if (!Uri.TryCreate(tenantUrl, UriKind.Absolute, out var uri))
|
|
||||||
return tenantUrl;
|
|
||||||
var adminHost = uri.Host.Replace(".sharepoint.com", "-admin.sharepoint.com",
|
|
||||||
StringComparison.OrdinalIgnoreCase);
|
|
||||||
return $"{uri.Scheme}://{adminHost}";
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,19 +7,27 @@ public class UserAccessAuditService : IUserAccessAuditService
|
|||||||
{
|
{
|
||||||
private readonly IPermissionsService _permissionsService;
|
private readonly IPermissionsService _permissionsService;
|
||||||
private readonly IElevationCoordinator _elevation;
|
private readonly IElevationCoordinator _elevation;
|
||||||
|
private readonly ISharePointGroupResolver _groupResolver;
|
||||||
|
|
||||||
private static readonly HashSet<string> HighPrivilegeLevels = new(StringComparer.OrdinalIgnoreCase)
|
private static readonly HashSet<string> HighPrivilegeLevels = new(StringComparer.OrdinalIgnoreCase)
|
||||||
{
|
{
|
||||||
"Full Control", "Site Collection Administrator"
|
"Full Control", "Site Collection Administrator"
|
||||||
};
|
};
|
||||||
|
|
||||||
public UserAccessAuditService(IPermissionsService permissionsService, IElevationCoordinator elevation)
|
private static readonly IReadOnlyDictionary<string, IReadOnlyList<ResolvedMember>> NoGroupMembers =
|
||||||
|
new Dictionary<string, IReadOnlyList<ResolvedMember>>();
|
||||||
|
|
||||||
|
public UserAccessAuditService(
|
||||||
|
IPermissionsService permissionsService,
|
||||||
|
IElevationCoordinator elevation,
|
||||||
|
ISharePointGroupResolver groupResolver)
|
||||||
{
|
{
|
||||||
_permissionsService = permissionsService;
|
_permissionsService = permissionsService;
|
||||||
_elevation = elevation;
|
_elevation = elevation;
|
||||||
|
_groupResolver = groupResolver;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<IReadOnlyList<UserAccessEntry>> AuditUsersAsync(
|
public async Task<UserAccessAuditResult> AuditUsersAsync(
|
||||||
ISessionManager sessionManager,
|
ISessionManager sessionManager,
|
||||||
TenantProfile currentProfile,
|
TenantProfile currentProfile,
|
||||||
IReadOnlyList<string> targetUserLogins,
|
IReadOnlyList<string> targetUserLogins,
|
||||||
@@ -32,10 +40,16 @@ public class UserAccessAuditService : IUserAccessAuditService
|
|||||||
.Select(l => l.Trim().ToLowerInvariant())
|
.Select(l => l.Trim().ToLowerInvariant())
|
||||||
.Where(l => l.Length > 0).ToHashSet();
|
.Where(l => l.Length > 0).ToHashSet();
|
||||||
|
|
||||||
if (targets.Count == 0) return Array.Empty<UserAccessEntry>();
|
if (targets.Count == 0) return new UserAccessAuditResult(Array.Empty<UserAccessEntry>(), 0, 0, 0);
|
||||||
|
|
||||||
var allEntries = new List<UserAccessEntry>();
|
var allEntries = new List<UserAccessEntry>();
|
||||||
|
|
||||||
|
// Per-site resilience: when auditing many sites (e.g. the whole tenant), one bad site
|
||||||
|
// must not abort the run. Access-denied is the expected "tech has no access here" case
|
||||||
|
// and is skipped quietly; any other error is skipped but counted as a failure so it can
|
||||||
|
// be surfaced. Cancellation always propagates.
|
||||||
|
int deniedSites = 0, failedSites = 0;
|
||||||
|
|
||||||
for (int i = 0; i < sites.Count; i++)
|
for (int i = 0; i < sites.Count; i++)
|
||||||
{
|
{
|
||||||
ct.ThrowIfCancellationRequested();
|
ct.ThrowIfCancellationRequested();
|
||||||
@@ -43,64 +57,157 @@ public class UserAccessAuditService : IUserAccessAuditService
|
|||||||
progress.Report(new OperationProgress(i, sites.Count,
|
progress.Report(new OperationProgress(i, sites.Count,
|
||||||
$"Scanning site {i + 1}/{sites.Count}: {site.Title}..."));
|
$"Scanning site {i + 1}/{sites.Count}: {site.Title}..."));
|
||||||
|
|
||||||
var profile = new TenantProfile
|
var profile = currentProfile.CloneForSite(site.Url);
|
||||||
{
|
profile.Name = site.Title;
|
||||||
TenantUrl = site.Url,
|
|
||||||
TenantId = currentProfile.TenantId,
|
|
||||||
ClientId = currentProfile.ClientId,
|
|
||||||
Name = site.Title
|
|
||||||
};
|
|
||||||
|
|
||||||
// Auto-elevates site-collection admin ownership and retries when a scan is denied,
|
try
|
||||||
// if the AutoTakeOwnership setting is enabled (otherwise the access-denied propagates).
|
|
||||||
var permEntries = await _elevation.RunAsync(async c =>
|
|
||||||
{
|
{
|
||||||
var ctx = await sessionManager.GetOrCreateContextAsync(profile, c);
|
// Auto-elevates site-collection admin ownership and retries when a scan is denied,
|
||||||
return await _permissionsService.ScanSiteAsync(ctx, options, progress, c);
|
// if the AutoTakeOwnership setting is enabled (otherwise the access-denied propagates).
|
||||||
}, ct);
|
var permEntries = await _elevation.RunAsync(async c =>
|
||||||
|
{
|
||||||
|
var ctx = await sessionManager.GetOrCreateContextAsync(profile, c);
|
||||||
|
return await _permissionsService.ScanSiteAsync(ctx, options, progress, c);
|
||||||
|
}, ct);
|
||||||
|
|
||||||
allEntries.AddRange(TransformEntries(permEntries, targets, site));
|
// Most users get access through SharePoint group membership, not direct grants.
|
||||||
|
// The scan records the group as a single principal, so the group's members must be
|
||||||
|
// expanded (including nested AAD groups, via the resolver) for a user-centric audit
|
||||||
|
// to attribute that access to the target — without it the audit finds nothing.
|
||||||
|
var siteCtx = await sessionManager.GetOrCreateContextAsync(profile, ct);
|
||||||
|
var groupMembers = await ResolveGroupMembersAsync(_groupResolver, siteCtx, profile, permEntries, ct);
|
||||||
|
|
||||||
|
allEntries.AddRange(TransformEntries(permEntries, targets, site, groupMembers));
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException)
|
||||||
|
{
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
catch (SharePointAccessDeniedException)
|
||||||
|
{
|
||||||
|
// No access to this site (and elevation could not / was not allowed to fix it).
|
||||||
|
// Expected when scanning the whole tenant under a delegated identity — skip.
|
||||||
|
deniedSites++;
|
||||||
|
}
|
||||||
|
catch (Exception)
|
||||||
|
{
|
||||||
|
// Transient/throttling/malformed-site error — skip and keep going.
|
||||||
|
failedSites++;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
progress.Report(new OperationProgress(sites.Count, sites.Count,
|
var summary = $"Audit complete: {allEntries.Count} access entries found.";
|
||||||
$"Audit complete: {allEntries.Count} access entries found."));
|
if (deniedSites > 0) summary += $" {deniedSites} site(s) skipped (no access).";
|
||||||
return allEntries;
|
if (failedSites > 0) summary += $" {failedSites} site(s) failed.";
|
||||||
|
progress.Report(new OperationProgress(sites.Count, sites.Count, summary));
|
||||||
|
return new UserAccessAuditResult(allEntries, sites.Count, deniedSites, failedSites);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static IEnumerable<UserAccessEntry> TransformEntries(
|
/// <summary>
|
||||||
IReadOnlyList<PermissionEntry> permEntries, HashSet<string> targets, SiteInfo site)
|
/// Resolves every SharePoint group referenced by <paramref name="permEntries"/> to its
|
||||||
|
/// member set (expanding nested AAD groups via Graph), so group-granted access can be
|
||||||
|
/// attributed to the target user. Returns an empty map if there are no group principals
|
||||||
|
/// or resolution fails (the audit then falls back to direct grants only rather than abort).
|
||||||
|
/// Shared by the interactive audit and the background report scheduler.
|
||||||
|
/// </summary>
|
||||||
|
public static async Task<IReadOnlyDictionary<string, IReadOnlyList<ResolvedMember>>> ResolveGroupMembersAsync(
|
||||||
|
ISharePointGroupResolver resolver,
|
||||||
|
Microsoft.SharePoint.Client.ClientContext ctx,
|
||||||
|
TenantProfile profile,
|
||||||
|
IReadOnlyList<PermissionEntry> permEntries,
|
||||||
|
CancellationToken ct)
|
||||||
|
{
|
||||||
|
var groupNames = permEntries
|
||||||
|
.Where(e => string.Equals(e.PrincipalType, "SharePointGroup", StringComparison.OrdinalIgnoreCase))
|
||||||
|
.Select(e => e.Users) // group title; matches GrantedThrough "SharePoint Group: {title}"
|
||||||
|
.Where(n => !string.IsNullOrWhiteSpace(n))
|
||||||
|
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
if (groupNames.Count == 0) return NoGroupMembers;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return await resolver.ResolveGroupsAsync(ctx, profile, groupNames, ct);
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException) { throw; }
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Serilog.Log.Warning("User-access audit: SP group expansion failed on {Url}: {Error}", ctx.Url, ex.Message);
|
||||||
|
return NoGroupMembers;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Projects raw permission entries for one site into per-user access entries, keeping only
|
||||||
|
/// rows touching one of <paramref name="targets"/> (substring match on login). Direct user
|
||||||
|
/// grants match the principal's own login; SharePoint-group grants match against the group's
|
||||||
|
/// expanded membership in <paramref name="groupMembers"/> (see <see cref="ResolveGroupMembersAsync"/>),
|
||||||
|
/// so access held through a group is attributed to its members. Exposed for the background
|
||||||
|
/// report scheduler, which reuses this projection under app-only auth.
|
||||||
|
/// </summary>
|
||||||
|
internal static IEnumerable<UserAccessEntry> TransformEntries(
|
||||||
|
IReadOnlyList<PermissionEntry> permEntries, HashSet<string> targets, SiteInfo site,
|
||||||
|
IReadOnlyDictionary<string, IReadOnlyList<ResolvedMember>>? groupMembers = null)
|
||||||
{
|
{
|
||||||
foreach (var entry in permEntries)
|
foreach (var entry in permEntries)
|
||||||
{
|
{
|
||||||
var logins = entry.UserLogins.Split(';', StringSplitOptions.RemoveEmptyEntries);
|
|
||||||
var names = entry.Users.Split(';', StringSplitOptions.RemoveEmptyEntries);
|
|
||||||
var permLevels = entry.PermissionLevels.Split(';', StringSplitOptions.RemoveEmptyEntries);
|
var permLevels = entry.PermissionLevels.Split(';', StringSplitOptions.RemoveEmptyEntries);
|
||||||
|
|
||||||
|
UserAccessEntry Build(string displayName, string login, AccessType accessType, string level) =>
|
||||||
|
new(displayName, StripClaimsPrefix(login),
|
||||||
|
site.Url, site.Title,
|
||||||
|
entry.ObjectType, entry.Title, entry.Url,
|
||||||
|
level, accessType, entry.GrantedThrough,
|
||||||
|
HighPrivilegeLevels.Contains(level),
|
||||||
|
PermissionEntryHelper.IsExternalUser(login),
|
||||||
|
entry.TargetUrl, entry.TargetLabel, entry.SharingLinkType);
|
||||||
|
|
||||||
|
bool isGroup = string.Equals(entry.PrincipalType, "SharePointGroup", StringComparison.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
if (isGroup)
|
||||||
|
{
|
||||||
|
// Group principal: match the target against the group's expanded members. Without a
|
||||||
|
// resolved map (or an unknown group) there is no user to attribute access to — skip.
|
||||||
|
if (groupMembers is null
|
||||||
|
|| string.IsNullOrEmpty(entry.Users)
|
||||||
|
|| !groupMembers.TryGetValue(entry.Users, out var members))
|
||||||
|
continue;
|
||||||
|
|
||||||
|
var accessType = entry.HasUniquePermissions ? AccessType.Group : AccessType.Inherited;
|
||||||
|
foreach (var m in members)
|
||||||
|
{
|
||||||
|
var loginLower = m.Login.ToLowerInvariant();
|
||||||
|
if (string.IsNullOrEmpty(loginLower)) continue;
|
||||||
|
if (!targets.Any(t => loginLower.Contains(t) || t.Contains(loginLower))) continue;
|
||||||
|
|
||||||
|
foreach (var level in permLevels)
|
||||||
|
{
|
||||||
|
var trimmed = level.Trim();
|
||||||
|
if (string.IsNullOrEmpty(trimmed)) continue;
|
||||||
|
yield return Build(m.DisplayName, m.Login, accessType, trimmed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Direct / external user grant (also the joined site-collection-admins entry).
|
||||||
|
var logins = entry.UserLogins.Split(';', StringSplitOptions.RemoveEmptyEntries);
|
||||||
|
var names = entry.Users.Split(';', StringSplitOptions.RemoveEmptyEntries);
|
||||||
for (int u = 0; u < logins.Length; u++)
|
for (int u = 0; u < logins.Length; u++)
|
||||||
{
|
{
|
||||||
var login = logins[u].Trim();
|
var login = logins[u].Trim();
|
||||||
var loginLower = login.ToLowerInvariant();
|
var loginLower = login.ToLowerInvariant();
|
||||||
var displayName = u < names.Length ? names[u].Trim() : login;
|
var displayName = u < names.Length ? names[u].Trim() : login;
|
||||||
|
|
||||||
bool isTarget = targets.Any(t => loginLower.Contains(t) || t.Contains(loginLower));
|
if (!targets.Any(t => loginLower.Contains(t) || t.Contains(loginLower))) continue;
|
||||||
if (!isTarget) continue;
|
|
||||||
|
|
||||||
var accessType = !entry.HasUniquePermissions ? AccessType.Inherited
|
|
||||||
: entry.GrantedThrough.StartsWith("SharePoint Group:", StringComparison.OrdinalIgnoreCase)
|
|
||||||
? AccessType.Group : AccessType.Direct;
|
|
||||||
|
|
||||||
|
var accessType = entry.HasUniquePermissions ? AccessType.Direct : AccessType.Inherited;
|
||||||
foreach (var level in permLevels)
|
foreach (var level in permLevels)
|
||||||
{
|
{
|
||||||
var trimmed = level.Trim();
|
var trimmed = level.Trim();
|
||||||
if (string.IsNullOrEmpty(trimmed)) continue;
|
if (string.IsNullOrEmpty(trimmed)) continue;
|
||||||
yield return new UserAccessEntry(
|
yield return Build(displayName, login, accessType, trimmed);
|
||||||
displayName, StripClaimsPrefix(login),
|
|
||||||
site.Url, site.Title,
|
|
||||||
entry.ObjectType, entry.Title, entry.Url,
|
|
||||||
trimmed, accessType, entry.GrantedThrough,
|
|
||||||
HighPrivilegeLevels.Contains(trimmed),
|
|
||||||
PermissionEntryHelper.IsExternalUser(login),
|
|
||||||
entry.TargetUrl, entry.TargetLabel, entry.SharingLinkType);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+35
-18
@@ -1,35 +1,35 @@
|
|||||||
:root {
|
:root {
|
||||||
--sidebar-width: 248px;
|
--sidebar-width: 248px;
|
||||||
--sidebar-collapsed-width: 78px;
|
--sidebar-collapsed-width: 78px;
|
||||||
--bg: #eef0f7;
|
--bg: #eff6ff;
|
||||||
--page-bg: #eef0f7;
|
--page-bg: #eff6ff;
|
||||||
--sidebar-bg: #ffffff;
|
--sidebar-bg: #ffffff;
|
||||||
--sidebar-text: #3f4254;
|
--sidebar-text: #3f4254;
|
||||||
--sidebar-muted: #8a8d9b;
|
--sidebar-muted: #8a8d9b;
|
||||||
--sidebar-hover: #f2f3f9;
|
--sidebar-hover: #eff6ff;
|
||||||
--sidebar-accent: #5b5bd6;
|
--sidebar-accent: #006cd2;
|
||||||
--sidebar-active: #5b5bd6;
|
--sidebar-active: #006cd2;
|
||||||
--card-bg: #fff;
|
--card-bg: #fff;
|
||||||
--surface-hover: #f2f3f9;
|
--surface-hover: #eff6ff;
|
||||||
--th-bg: #f4f5fb;
|
--th-bg: #eff6ff;
|
||||||
--input-bg: #fff;
|
--input-bg: #fff;
|
||||||
--border: #e6e7f0;
|
--border: #d8e6f5;
|
||||||
--accent: #5b5bd6;
|
--accent: #006cd2;
|
||||||
--accent-dark: #4a4ac0;
|
--accent-dark: #092c55;
|
||||||
--accent-soft: rgba(91,91,214,.12);
|
--accent-soft: rgba(0,108,210,.12);
|
||||||
--danger: #d13438;
|
--danger: #d13438;
|
||||||
--success: #107c10;
|
--success: #107c10;
|
||||||
--warn: #797673;
|
--warn: #fea20a;
|
||||||
--text: #323130;
|
--text: #092c55;
|
||||||
--text-muted: #605e5c;
|
--text-muted: #5a6b80;
|
||||||
--surface-2: #2d2d4e;
|
--surface-2: #092c55;
|
||||||
--font: 'Segoe UI', system-ui, sans-serif;
|
--font: 'Segoe UI', system-ui, sans-serif;
|
||||||
/* shape + depth — match sidebar */
|
/* shape + depth — match sidebar */
|
||||||
--radius-lg: 20px;
|
--radius-lg: 20px;
|
||||||
--radius-md: 12px;
|
--radius-md: 12px;
|
||||||
--radius-sm: 10px;
|
--radius-sm: 10px;
|
||||||
--shadow-card: 0 10px 34px rgba(30,30,70,.10);
|
--shadow-card: 0 10px 34px rgba(30,30,70,.10);
|
||||||
--shadow-soft: 0 6px 16px rgba(91,91,214,.22);
|
--shadow-soft: 0 6px 16px rgba(0,108,210,.22);
|
||||||
}
|
}
|
||||||
|
|
||||||
*, *::before, *::after { box-sizing: border-box; }
|
*, *::before, *::after { box-sizing: border-box; }
|
||||||
@@ -133,7 +133,7 @@ body {
|
|||||||
.nav-item:hover { background: var(--sidebar-hover); }
|
.nav-item:hover { background: var(--sidebar-hover); }
|
||||||
.nav-item.active {
|
.nav-item.active {
|
||||||
background: var(--sidebar-accent); color: #fff;
|
background: var(--sidebar-accent); color: #fff;
|
||||||
box-shadow: 0 6px 16px rgba(91,91,214,.35);
|
box-shadow: 0 6px 16px rgba(0,108,210,.35);
|
||||||
}
|
}
|
||||||
.nav-icon { font-size: 16px; min-width: 22px; text-align: center; }
|
.nav-icon { font-size: 16px; min-width: 22px; text-align: center; }
|
||||||
.nav-label { overflow: hidden; text-overflow: ellipsis; }
|
.nav-label { overflow: hidden; text-overflow: ellipsis; }
|
||||||
@@ -371,9 +371,26 @@ body {
|
|||||||
100% { margin-left: 100%; }
|
100% { margin-left: 100%; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── User→Sites access drill-down ── */
|
||||||
|
.site-drill { border: 1px solid var(--border); border-radius: var(--radius-md); overflow: hidden; margin-bottom: 8px; background: var(--card-bg); }
|
||||||
|
.site-drill-header {
|
||||||
|
display: flex; align-items: center; gap: 10px; width: 100%;
|
||||||
|
padding: 11px 14px; border: none; background: none; cursor: pointer;
|
||||||
|
font-family: inherit; font-size: 13.5px; text-align: left; color: var(--text);
|
||||||
|
transition: background .12s;
|
||||||
|
}
|
||||||
|
.site-drill-header:hover { background: var(--surface-hover); }
|
||||||
|
.site-drill.open .site-drill-header { background: var(--surface-hover); }
|
||||||
|
.drill-caret { color: var(--text-muted); font-size: 11px; transition: transform .15s; flex-shrink: 0; }
|
||||||
|
.drill-caret.open { transform: rotate(90deg); }
|
||||||
|
.drill-title { font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||||
|
.drill-url { font-size: 11px; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.site-drill-body { border-top: 1px solid var(--border); }
|
||||||
|
.site-drill-body .data-table-wrap { border: none; border-radius: 0; }
|
||||||
|
|
||||||
/* ── Feature cards (Home) ── */
|
/* ── Feature cards (Home) ── */
|
||||||
.feature-card { cursor: pointer; transition: box-shadow .15s, transform .15s; }
|
.feature-card { cursor: pointer; transition: box-shadow .15s, transform .15s; }
|
||||||
.feature-card:hover { box-shadow: 0 14px 36px rgba(91, 91, 214, .22); transform: translateY(-2px); }
|
.feature-card:hover { box-shadow: 0 14px 36px rgba(0, 108, 210, .22); transform: translateY(-2px); }
|
||||||
|
|
||||||
/* ── Visual folder-structure builder ── */
|
/* ── Visual folder-structure builder ── */
|
||||||
.folder-builder { display: flex; flex-direction: column; gap: 6px; padding: 10px; border: 1px solid var(--border); border-radius: var(--radius-sm); background: var(--surface); }
|
.folder-builder { display: flex; flex-direction: column; gap: 6px; padding: 10px; border: 1px solid var(--border); border-radius: var(--radius-sm); background: var(--surface); }
|
||||||
|
|||||||
Reference in New Issue
Block a user