From 6d9c79ad5a18f7038c06ad7d7a26c0beef604541 Mon Sep 17 00:00:00 2001 From: kawa Date: Mon, 8 Jun 2026 17:55:28 +0200 Subject: [PATCH] Add scheduled reports + app-only cert auth; fix tenant-wide user-access audit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Feature work: - Certificate (app-only) auth per profile: cert store, context/Graph client factories, automated app-registration provisioning (delegated + application permissions, admin consent), and a SessionManager seam that resolves the auth model per profile. - Scheduled reports: repositories, hosted service/runner/coordinator, report pages, and email delivery (app-only Mail.Send). - Tenant-wide user-access audit when no site is selected. Audit fixes: - Site enumeration: app-only discovery used Graph getAllSites (needs Graph Sites.Read.All the cert app lacks) and silently returned empty. Switched to the admin-host CSOM TenantSiteEnumerator, matching the scheduler; both auth models now share one enumeration path. - Group expansion: the scan records a SharePoint group as a single principal, so user-centric audits found nothing for group-granted access. Resolve group membership (shared by audit + scheduler) and attribute it to the target user. - M365 group claims: the resolver only recognized AAD security groups (c:0t.c|). Group-connected/Teams sites grant via the M365 group claim (c:0o.c|…|[_o]); now expanded too, resolving owners for the "_o" claim. - Provision Directory.Read.All as an application permission so M365/AAD group expansion works under the cert identity. Also: ignore data/appcerts/ (encrypted certificate key material). Co-Authored-By: Claude Opus 4.8 (1M context) --- .gitignore | 1 + Components/Layout/MainLayout.razor | 29 +- Components/Pages/Duplicates.razor | 2 +- Components/Pages/Profiles.razor | 186 ++++++- Components/Pages/Reports.razor | 101 ++++ Components/Pages/ScheduledReports.razor | 499 ++++++++++++++++++ Components/Pages/UserAccessAudit.razor | 139 ++++- Core/Helpers/TenantSiteEnumerator.cs | 92 ++++ Core/Models/AppConfiguration.cs | 3 + Core/Models/GeneratedReport.cs | 51 ++ Core/Models/ReportType.cs | 12 + Core/Models/ScheduledReport.cs | 196 +++++++ Core/Models/TenantProfile.cs | 37 ++ Core/Models/UserAccessAuditResult.cs | 12 + Infrastructure/Auth/AppOnlyCertStore.cs | 70 +++ Infrastructure/Auth/AppOnlyContextFactory.cs | 120 +++++ Infrastructure/Auth/GraphClientFactory.cs | 16 +- Infrastructure/Auth/IAppOnlyCertStore.cs | 24 + Infrastructure/Auth/IAppOnlyContextFactory.cs | 47 ++ Infrastructure/Auth/SessionManager.cs | 45 +- .../Persistence/GeneratedReportRepository.cs | 87 +++ .../Persistence/ScheduledReportRepository.cs | 91 ++++ Localization/Strings.fr.resx | 38 ++ Localization/Strings.resx | 38 ++ Program.cs | 34 ++ Services/Auth/AppRegistrationService.cs | 156 ++++-- Services/Auth/CertProvisioningService.cs | 57 ++ Services/Auth/IAppRegistrationService.cs | 13 +- Services/Auth/ICertProvisioningService.cs | 25 + Services/IUserAccessAuditService.cs | 2 +- Services/Reports/IReportMailService.cs | 21 + Services/Reports/IScheduledReportRunner.cs | 14 + Services/Reports/ReportMailService.cs | 90 ++++ .../Reports/ScheduledReportHostedService.cs | 130 +++++ Services/Reports/ScheduledReportRunner.cs | 297 +++++++++++ Services/Reports/ScheduledRunCoordinator.cs | 78 +++ Services/SharePointGroupResolver.cs | 63 ++- Services/SiteDiscoveryService.cs | 137 ++--- Services/UserAccessAuditService.cs | 183 +++++-- wwwroot/app.css | 53 +- 40 files changed, 3020 insertions(+), 269 deletions(-) create mode 100644 Components/Pages/Reports.razor create mode 100644 Components/Pages/ScheduledReports.razor create mode 100644 Core/Helpers/TenantSiteEnumerator.cs create mode 100644 Core/Models/GeneratedReport.cs create mode 100644 Core/Models/ReportType.cs create mode 100644 Core/Models/ScheduledReport.cs create mode 100644 Core/Models/UserAccessAuditResult.cs create mode 100644 Infrastructure/Auth/AppOnlyCertStore.cs create mode 100644 Infrastructure/Auth/AppOnlyContextFactory.cs create mode 100644 Infrastructure/Auth/IAppOnlyCertStore.cs create mode 100644 Infrastructure/Auth/IAppOnlyContextFactory.cs create mode 100644 Infrastructure/Persistence/GeneratedReportRepository.cs create mode 100644 Infrastructure/Persistence/ScheduledReportRepository.cs create mode 100644 Services/Auth/CertProvisioningService.cs create mode 100644 Services/Auth/ICertProvisioningService.cs create mode 100644 Services/Reports/IReportMailService.cs create mode 100644 Services/Reports/IScheduledReportRunner.cs create mode 100644 Services/Reports/ReportMailService.cs create mode 100644 Services/Reports/ScheduledReportHostedService.cs create mode 100644 Services/Reports/ScheduledReportRunner.cs create mode 100644 Services/Reports/ScheduledRunCoordinator.cs diff --git a/.gitignore b/.gitignore index 7d74565..2e3422b 100644 --- a/.gitignore +++ b/.gitignore @@ -64,3 +64,4 @@ data/logs/ data/exports/ data/templates/ data/audit.jsonl +data/appcerts/ diff --git a/Components/Layout/MainLayout.razor b/Components/Layout/MainLayout.razor index 2d3a5dd..b51fd15 100644 --- a/Components/Layout/MainLayout.razor +++ b/Components/Layout/MainLayout.razor @@ -4,6 +4,7 @@ @inject IUserContextAccessor UserContext @inject ISessionCredentialStore CredStore @inject ISessionManager SessionManager +@inject SharepointToolbox.Web.Infrastructure.Auth.IAppOnlyContextFactory AppOnly @inject NavigationManager Nav @inject IJSRuntime JS @inject SharepointToolbox.Web.Services.OAuth.IOAuthFlowCache OAuthCache @@ -44,8 +45,11 @@ {
SP: @_credUsername - + @if (!CurrentProfileUsesCert) + { + + }
} @@ -149,7 +153,9 @@ new("/folder-structure", "πŸ“", "tab.folderStructure", "nav.section.bulk", "profile"), new("/user-audit", "πŸ‘€", "tab.userAccessAudit", "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("/scheduled-reports", "⏰", "nav.scheduledReports", "nav.section.admin", "admin"), new("/profiles", "βš™οΈ", "nav.clientProfiles", "nav.section.admin", "admin"), new("/admin/users", "πŸ‘₯", "nav.userManagement", "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 (Session.HasProfile && !_hasCredentials && _credModal is not null) + // If profile selected but no credentials β†’ show modal (cert profiles never prompt) + if (Session.HasProfile && !_hasCredentials && !CurrentProfileUsesCert && _credModal is not null) 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() { var uri = new Uri(Nav.Uri); @@ -256,6 +267,16 @@ 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(); // Session tokens are tenant-bound (refresh token issued for the profile's TenantId/ClientId). diff --git a/Components/Pages/Duplicates.razor b/Components/Pages/Duplicates.razor index 64ab559..3dcf2fa 100644 --- a/Components/Pages/Duplicates.razor +++ b/Components/Pages/Duplicates.razor @@ -61,7 +61,7 @@ @foreach (var g in _results.Take(100)) {
-
+
@g.Name @g.Items.Count @T["report.text.copies"]
@foreach (var item in g.Items) diff --git a/Components/Pages/Profiles.razor b/Components/Pages/Profiles.razor index ee4e51e..4149ce1 100644 --- a/Components/Pages/Profiles.razor +++ b/Components/Pages/Profiles.razor @@ -3,13 +3,17 @@ @inject IUserSessionService Session @inject IUserContextAccessor UserContext @inject SharepointToolbox.Web.Infrastructure.Persistence.ProfileRepository ProfileRepo +@inject SharepointToolbox.Web.Infrastructure.Auth.IAppOnlyCertStore CertStore +@inject SharepointToolbox.Web.Infrastructure.Auth.IAppOnlyContextFactory AppOnlyFactory @inject ISessionCredentialStore CredStore @inject NavigationManager Nav @inject SharepointToolbox.Web.Services.OAuth.IEntraDeviceCodeFlow DeviceFlow @inject SharepointToolbox.Web.Services.Auth.IAppRegistrationService AppRegService +@inject SharepointToolbox.Web.Services.Auth.ICertProvisioningService CertProvisioning @inject Microsoft.Extensions.Options.IOptions ConnectOpts @inject TranslationSource T @rendermode InteractiveServer +@using Microsoft.AspNetCore.Components.Forms @using Microsoft.AspNetCore.WebUtilities @using SharepointToolbox.Web.Core.Models @using SharepointToolbox.Web.Services.Session @@ -24,7 +28,7 @@ @foreach (var p in _profiles) { -
+
@p.Name
@@ -62,7 +66,7 @@ @foreach (var p in _profiles) { -
+
@p.Name
@@ -86,7 +90,7 @@ @if (_showForm) { -
+
@(_editing?.Id == null ? T["profiles.form.new"] : T["profiles.form.edit"])
@if (!string.IsNullOrEmpty(_formError)) { @@ -151,6 +155,64 @@
+ @* ── App-only credentials for scheduled (unattended) reports ── *@ +
+ + @if (_editing is null) + { +
Save this client first, then re-open it to configure certificate credentials.
+ } + else + { + + When enabled, this client uses a certificate-based app registration with + application permissions (Sites.FullControl.All, admin-consented) for + both interactive work and scheduled reports. Technicians never sign in + to SharePoint per profile. The Register app button provisions the certificate + and consent automatically; the fields below are for manual setup. + + + + + + + + + @if (_certPresent) + { +
+ Certificate stored + @if (!string.IsNullOrEmpty(_form.AppOnlyCertThumbprint)) + { + @_form.AppOnlyCertThumbprint + } + +
+ } + else + { +
+ + + +
+ } + +
+ + @if (!string.IsNullOrEmpty(_appOnlyStatus)) { @_appOnlyStatus } +
+ } +
+
@@ -171,10 +233,12 @@ private string _regStatus = string.Empty; private CancellationTokenSource? _regCts; - // Graph delegated scopes the admin must consent to so we can create the app registration. + // Graph delegated scopes the admin must consent to so we can create the app registration, + // attach the certificate, and grant application-permission (app-role) admin consent. private const string RegistrationScope = "https://graph.microsoft.com/Application.ReadWrite.All " + "https://graph.microsoft.com/DelegatedPermissionGrant.ReadWrite.All " + + "https://graph.microsoft.com/AppRoleAssignment.ReadWrite.All " + "https://graph.microsoft.com/Directory.Read.All " + "openid offline_access"; @@ -219,9 +283,19 @@ private void EditProfile(TenantProfile p) { _editing = p; - _form = new TenantProfile { Id = p.Id, Name = p.Name, TenantUrl = p.TenantUrl, TenantId = p.TenantId, ClientId = p.ClientId, ClientLogo = p.ClientLogo }; - _showForm = true; + _form = new TenantProfile + { + Id = p.Id, Name = p.Name, TenantUrl = p.TenantUrl, TenantId = p.TenantId, + ClientId = p.ClientId, ClientLogo = p.ClientLogo, + AppOnlyEnabled = p.AppOnlyEnabled, AppOnlyClientId = p.AppOnlyClientId, + AppOnlyCertThumbprint = p.AppOnlyCertThumbprint + }; + _showForm = true; _formError = _pageError = string.Empty; + _certPresent = CertStore.Exists(p.Id); + _pfxBytes = null; + _certPassword = string.Empty; + _appOnlyStatus = string.Empty; } private void CancelForm() { _showForm = false; _editing = null; } @@ -256,14 +330,32 @@ _regStatus = T["profiles.reg.creating"]; StateHasChanged(); + // Generate + store the app-only certificate before creating the registration so its + // public key can be attached as a sign-in credential. Technicians then operate under + // the app identity and never sign in to SharePoint per profile. + var cert = await CertProvisioning.GenerateAndStoreAsync(_form.Id, $"SP Toolbox β€” {_form.Name}", _regCts.Token); + var clientId = await AppRegService.CreateAsync( adminAccessToken: adminToken, tenantName: _form.Name, redirectUri: ConnectOpts.Value.RedirectUri, + appOnlyCert: cert, ct: _regCts.Token); - _form.ClientId = clientId; - _regStatus = T["profiles.reg.registered"]; + _form.ClientId = clientId; + _form.AppOnlyClientId = clientId; + _form.AppOnlyEnabled = true; + _form.AppOnlyCertThumbprint = cert.Thumbprint; + _certPresent = true; + + // Cert key credential + app-role consent take time to propagate through Entra; + // wait it out so the profile is usable immediately instead of 401ing on first use. + _regStatus = T["profiles.reg.propagating"]; + StateHasChanged(); + var notReady = await AppOnlyFactory.WaitUntilReadyAsync(_form, TimeSpan.FromSeconds(90), _regCts.Token); + _regStatus = notReady is null + ? T["profiles.reg.registered"] + : string.Format(T["profiles.reg.notready"], notReady); } catch (OperationCanceledException) { @@ -317,6 +409,84 @@ { _profiles.RemoveAll(x => x.Id == p.Id); await ProfileRepo.SaveAsync(_profiles); + CertStore.Delete(p.Id); if (Session.CurrentProfile?.Id == p.Id) await Session.ClearSessionAsync(); } + + // ── App-only credential handlers ─────────────────────────────────────────── + private const long MaxCertBytes = 256 * 1024; + private byte[]? _pfxBytes; + private string _certPassword = string.Empty; + private bool _certPresent; + private bool _appOnlyTesting; + private string _appOnlyStatus = string.Empty; + + private async Task OnCertSelected(InputFileChangeEventArgs e) + { + _appOnlyStatus = string.Empty; + var file = e.File; + if (file is null) return; + if (file.Size > MaxCertBytes) { _appOnlyStatus = $"Certificate too large (max {MaxCertBytes / 1024} KB)."; _pfxBytes = null; return; } + try + { + using var ms = new MemoryStream(); + await file.OpenReadStream(MaxCertBytes).CopyToAsync(ms); + _pfxBytes = ms.ToArray(); + } + catch (Exception ex) { _appOnlyStatus = $"Failed to read certificate: {ex.Message}"; _pfxBytes = null; } + } + + private async Task UploadCertAsync() + { + if (_pfxBytes is null || _editing is null) return; + try + { + var thumbprint = await CertStore.SaveAsync(_form.Id, _pfxBytes, string.IsNullOrEmpty(_certPassword) ? null : _certPassword); + _form.AppOnlyCertThumbprint = thumbprint; + _certPresent = true; + _pfxBytes = null; + _certPassword = string.Empty; + await PersistFormAsync(); + _appOnlyStatus = "Certificate stored."; + } + catch (Exception ex) { _appOnlyStatus = $"Certificate rejected: {ex.Message}"; } + } + + private async Task RemoveCertAsync() + { + if (_editing is null) return; + CertStore.Delete(_form.Id); + _certPresent = false; + _form.AppOnlyCertThumbprint = string.Empty; + await PersistFormAsync(); + _appOnlyStatus = "Certificate removed."; + } + + private async Task TestAppOnlyAsync() + { + if (_editing is null) return; + _appOnlyTesting = true; _appOnlyStatus = string.Empty; + try + { + // Persist current field edits first so the test uses what the admin sees. + await PersistFormAsync(); + var probe = new TenantProfile + { + Id = _form.Id, Name = _form.Name, TenantUrl = _form.TenantUrl, TenantId = _form.TenantId, + AppOnlyEnabled = true, AppOnlyClientId = _form.AppOnlyClientId + }; + var error = await AppOnlyFactory.TestConnectionAsync(probe); + _appOnlyStatus = error is null ? "βœ“ Connected successfully." : $"βœ— {error}"; + } + catch (Exception ex) { _appOnlyStatus = $"βœ— {ex.Message}"; } + finally { _appOnlyTesting = false; } + } + + // Upserts the in-progress form into the profile list and saves, without closing the form. + private async Task PersistFormAsync() + { + var idx = _profiles.FindIndex(p => p.Id == _form.Id); + if (idx >= 0) _profiles[idx] = _form; else _profiles.Add(_form); + await ProfileRepo.SaveAsync(_profiles); + } } diff --git a/Components/Pages/Reports.razor b/Components/Pages/Reports.razor new file mode 100644 index 0000000..691e28b --- /dev/null +++ b/Components/Pages/Reports.razor @@ -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 Cfg +@inject TranslationSource T +@rendermode InteractiveServer +@using SharepointToolbox.Web.Core.Models +@using SharepointToolbox.Web.Services.Session + +

Reports

+

Generated reports for the selected client.

+ +@if (!Session.HasProfile) { return; } + +
+
+
@Session.CurrentProfile!.Name @_reports.Count
+
+ +
+ + @if (_reports.Count == 0) + { +
No reports generated yet for this client. Schedules run automatically; an admin can create them under Scheduled Reports.
+ } + else + { +
+ + + + + + + + @foreach (var r in _reports) + { + + + + + + + + + } + +
NameTypeGenerated (UTC)SizeStatus
@(string.IsNullOrEmpty(r.Name) ? "β€”" : r.Name)@r.Type@r.GeneratedUtc.ToString("yyyy-MM-dd HH:mm")@(r.Status == ReportRunStatus.Success ? $"{r.SizeBytes / 1024.0:F1} KB" : "β€”") + @if (r.Status == ReportRunStatus.Success) + { + Success + @if (r.Emailed) + { + Emailed + } + else if (!string.IsNullOrEmpty(r.EmailError)) + { + Email failed + } + } + else + { + Failed + } + +
+ @if (r.Status == ReportRunStatus.Success) + { + Download + } + +
+
+
+ } +
+ +@code { + private List _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(); + } +} diff --git a/Components/Pages/ScheduledReports.razor b/Components/Pages/ScheduledReports.razor new file mode 100644 index 0000000..ff76233 --- /dev/null +++ b/Components/Pages/ScheduledReports.razor @@ -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 + +

Scheduled Reports

+

Automatic report generation per client. Generated files appear under Reports and are downloadable there.

+ +@if (UserContext.Role != UserRole.Admin) +{ +
Only administrators can manage scheduled reports.
+ return; +} + +@if (!string.IsNullOrEmpty(_pageMsg)) {
@_pageMsg
} + +@if (_appOnlyProfiles.Count == 0) +{ +
+ No client has app-only access enabled. Open a client under Client Profiles, + enable scheduled reports, and upload its certificate first. +
+} + +
+ +
+ @if (Coordinator.IsPaused) + { + Scheduler paused + + } + else + { + + } + +
+ +@if (_schedules.Count == 0 && !_showForm) +{ +
No schedules defined.
+} + +@foreach (var s in _schedules) +{ +
+
+
+
+ @(string.IsNullOrEmpty(s.Name) ? "(unnamed)" : s.Name) + @if (!s.Enabled) { Disabled } +
+
@ClientName(s.ProfileId) Β· @s.Type Β· @s.Format Β· @RecurrenceSummary(s.Recurrence)
+
+ @(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") +
+
+
+ + @if (Coordinator.IsRunning(s.Id)) + { + + } + + + +
+
+} + +@if (_showForm) +{ +
+
@(_editing is null ? "New schedule" : "Edit schedule")
+ @if (!string.IsNullOrEmpty(_formError)) {
@_formError
} + +
+ + +
+ +
+
+ + +
+
+ + +
+
+ + @if (_form.Type == ReportType.VersionCleanup) + { +
+ Destructive action. Version Cleanup permanently deletes 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). +
+ } + +
+
+ + +
+
+ + +
+
+ + @* ── Site scope ── *@ +
+ + @if (!_form.AllSites) + { + + + } +
+ + @* ── Recurrence ── *@ +
+
+ + +
+
+ + +
+ @if (_form.Recurrence.Frequency == ReportFrequency.Weekly) + { +
+ + +
+ } + else if (_form.Recurrence.Frequency == ReportFrequency.Monthly) + { +
+ + +
+ } +
+ + @* ── Type-specific options ── *@ +
+ + @switch (_form.Type) + { + case ReportType.Permissions: +
+ + + + +
+ break; + + case ReportType.Storage: +
+ + + + +
+ break; + + case ReportType.Duplicates: +
+ + + + +
+ break; + + case ReportType.Search: +
+ + + + +
+ break; + + case ReportType.UserAccess: + + +
+ + +
+ break; + + case ReportType.VersionCleanup: +
+ + + +
+ break; + } +
+ + @* ── Email delivery ── *@ +
+ + @if (_form.Email.Enabled) + { +
+ Sent through the client's app-only certificate (requires the Mail.Send application + permission β€” re-run onboarding if the app was registered before this was added). The report file is attached. +
+
+ + +
+
+
+ + +
+
+ + +
+
+
+ + +
+
+ + +
+ Placeholders: {ReportName} {ClientName} {ReportType} {FileName} {DateUtc} +
+
+ } +
+ +
+ +
+ +
+ + +
+
+} + +@code { + private List _schedules = new(); + private List _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(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(s.Email.To), Cc = new List(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(o.LibraryTitles), KeepLast = o.KeepLast, KeepFirst = o.KeepFirst, + Extensions = new List(o.Extensions), Regex = o.Regex, MaxResults = o.MaxResults, + TargetUserLogins = new List(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."; + } +} diff --git a/Components/Pages/UserAccessAudit.razor b/Components/Pages/UserAccessAudit.razor index c665b8f..a7ba987 100644 --- a/Components/Pages/UserAccessAudit.razor +++ b/Components/Pages/UserAccessAudit.razor @@ -4,6 +4,7 @@ @inject ISessionManager SessionMgr @inject IUserAccessAuditService AuditSvc @inject IGraphUserDirectoryService GraphSvc +@inject ISiteDiscoveryService SiteDiscovery @inject UserAccessCsvExportService CsvExport @inject UserAccessHtmlExportService HtmlExport @inject WebExportService WebExport @@ -56,6 +57,7 @@
+
@T["audit.hint.allSites"]
@@ -79,29 +81,92 @@
@T["audit.results.title"] @_results.Count
+ +
-
- - - - @foreach (var r in _results.Take(500)) + + @if (_sitesScanned > 0) + { +
+ @string.Format(T["audit.scan.sitesScanned"], _sitesScanned) + @if (_sitesDenied > 0) { @string.Format(T["audit.scan.sitesDenied"], _sitesDenied) } + @if (_sitesFailed > 0) { @string.Format(T["audit.scan.sitesFailed"], _sitesFailed) } +
+ } + + @if (_viewMode == "site") + { +
@T["audit.bysite.hint"]
+ @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); +
+ + @if (expanded) { -
- - - - - - - +
+
+
@T["report.col.user"]@T["report.col.site"]@T["report.col.object"]@T["audit.col.permission"]@T["report.col.access_type"]@T["report.col.granted_through"]
@r.UserDisplayName@r.SiteTitle@r.ObjectTitle (@r.ObjectType)@r.PermissionLevel @if (r.IsHighPrivilege) { @T["audit.chip.high"] }@r.AccessType@r.GrantedThrough
+ + @if (_multiUser) { } + + + + + + + @foreach (var r in g) + { + + @if (_multiUser) { } + + + + + + } + +
@T["report.col.user"]@T["report.col.object"]@T["audit.col.permission"]@T["report.col.access_type"]@T["report.col.granted_through"]
@r.UserDisplayName@r.ObjectTitle (@r.ObjectType)@r.PermissionLevel @if (r.IsHighPrivilege) { @T["audit.chip.high"] }@r.AccessType@r.GrantedThrough
+
+
} - - -
- @if (_results.Count > 500) {
@T["audit.msg.showFirst500Export"]
} +
+ } + } + else + { +
+ + + + @foreach (var r in _results.Take(500)) + { + + + + + + + + + } + +
@T["report.col.user"]@T["report.col.site"]@T["report.col.object"]@T["audit.col.permission"]@T["report.col.access_type"]@T["report.col.granted_through"]
@r.UserDisplayName@r.SiteTitle@r.ObjectTitle (@r.ObjectType)@r.PermissionLevel @if (r.IsHighPrivilege) { @T["audit.chip.high"] }@r.AccessType@r.GrantedThrough
+
+ @if (_results.Count > 500) {
@T["audit.msg.showFirst500Export"]
} + }
} @@ -147,26 +212,58 @@ private bool _running; private string _status = string.Empty, _error = string.Empty; private int _current, _total; private List _results = new(); + private int _sitesScanned, _sitesDenied, _sitesFailed; 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 _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() { - _error = string.Empty; _results.Clear(); _running = true; + _error = string.Empty; _results.Clear(); _expandedSites.Clear(); + _sitesScanned = _sitesDenied = _sitesFailed = 0; _running = true; _cts = new CancellationTokenSource(); var userList = _selectedEmails .Concat(_users.Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) .Distinct(StringComparer.OrdinalIgnoreCase).ToList(); if (!userList.Any()) { _error = T["audit.err.noUsersOrEmail"]; _running = false; return; } 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(p => { _status = p.Message; _current = p.Current; _total = p.Total; InvokeAsync(StateHasChanged); }); try { 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); 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 (Exception ex) { _error = ex.Message; } diff --git a/Core/Helpers/TenantSiteEnumerator.cs b/Core/Helpers/TenantSiteEnumerator.cs new file mode 100644 index 0000000..8c732aa --- /dev/null +++ b/Core/Helpers/TenantSiteEnumerator.cs @@ -0,0 +1,92 @@ +using Microsoft.Online.SharePoint.TenantAdministration; +using Microsoft.SharePoint.Client; +using SharepointToolbox.Web.Core.Models; + +namespace SharepointToolbox.Web.Core.Helpers; + +/// +/// Enumerates every site collection in a tenant via the SharePoint tenant-admin +/// endpoint (Tenant.GetSitePropertiesFromSharePointByFilters), paging through +/// all results. Shared by the delegated SiteDiscoveryService and the app-only +/// background report scheduler so both produce the identical, complete site set. +/// +/// The caller supplies a already pointed at the tenant +/// admin host (see ) 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. +/// +public static class TenantSiteEnumerator +{ + private const int MaxColdTokenAttempts = 4; + private const int BackoffBaseSeconds = 3; + + public static async Task> 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(); + 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 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); + } + } + } + + /// https://contoso.sharepoint.com[/sites/Foo] β†’ https://contoso-admin.sharepoint.com + 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}"; + } +} diff --git a/Core/Models/AppConfiguration.cs b/Core/Models/AppConfiguration.cs index 8ed7219..1cb283e 100644 --- a/Core/Models/AppConfiguration.cs +++ b/Core/Models/AppConfiguration.cs @@ -4,4 +4,7 @@ public class AppConfiguration { public string DataFolder { get; set; } = "/data"; public string ExportsFolder { get; set; } = "/data/exports"; + + /// DataProtection-encrypted app-only certificates, one file per client profile. + public string CertsFolder { get; set; } = "/data/appcerts"; } diff --git a/Core/Models/GeneratedReport.cs b/Core/Models/GeneratedReport.cs new file mode 100644 index 0000000..e79652f --- /dev/null +++ b/Core/Models/GeneratedReport.cs @@ -0,0 +1,51 @@ +namespace SharepointToolbox.Web.Core.Models; + +public enum ReportRunStatus +{ + Success, + Failed +} + +/// +/// 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. +/// +public class GeneratedReport +{ + public string Id { get; set; } = Guid.NewGuid().ToString(); + + public string ProfileId { get; set; } = string.Empty; + + /// The schedule that produced this; null for an ad-hoc/manual run. + public string? ScheduledReportId { get; set; } + + public ReportType Type { get; set; } + + /// Human label (usually copied from the schedule name). + public string Name { get; set; } = string.Empty; + + /// File name on disk, relative to the profile's exports subfolder. + 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; + + /// Populated when is Failed. + public string? Error { get; set; } + + /// True when the report was successfully emailed via Graph. + public bool Emailed { get; set; } + + /// + /// Populated when email delivery was requested but failed. The report itself still + /// succeeded (file is on disk) β€” only delivery failed. + /// + public string? EmailError { get; set; } +} diff --git a/Core/Models/ReportType.cs b/Core/Models/ReportType.cs new file mode 100644 index 0000000..6085d1f --- /dev/null +++ b/Core/Models/ReportType.cs @@ -0,0 +1,12 @@ +namespace SharepointToolbox.Web.Core.Models; + +/// The kinds of report that can be generated, scheduled, and exported. +public enum ReportType +{ + Permissions, + Storage, + Duplicates, + UserAccess, + VersionCleanup, + Search +} diff --git a/Core/Models/ScheduledReport.cs b/Core/Models/ScheduledReport.cs new file mode 100644 index 0000000..7495367 --- /dev/null +++ b/Core/Models/ScheduledReport.cs @@ -0,0 +1,196 @@ +using SharepointToolbox.Web.Services.Export; + +namespace SharepointToolbox.Web.Core.Models; + +/// How often a scheduled report recurs. +public enum ReportFrequency +{ + Daily, + Weekly, + Monthly +} + +/// +/// A recurrence rule. The report fires at on the cadence +/// described by . applies to Weekly; +/// applies to Monthly (clamped to the last day of short months). +/// All times are UTC to keep scheduling unambiguous across DST. +/// +public class RecurrenceRule +{ + public ReportFrequency Frequency { get; set; } = ReportFrequency.Weekly; + + /// Time of day to run, UTC, "HH:mm". + public string TimeOfDayUtc { get; set; } = "06:00"; + + /// 0 = Sunday … 6 = Saturday. Used when is Weekly. + public DayOfWeek DayOfWeek { get; set; } = DayOfWeek.Monday; + + /// 1–31. Used when is Monthly. + public int DayOfMonth { get; set; } = 1; + + /// + /// Computes the next fire time strictly after . + /// + 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); + } +} + +/// +/// Flat, serializable bag of the report-generation options used across all report +/// types. Only the fields relevant to the chosen +/// are honoured; the runner maps them to the concrete option records +/// (, , …). +/// +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 LibraryTitles { get; set; } = new(); + public int KeepLast { get; set; } = 5; + public bool KeepFirst { get; set; } + + // Search + public List 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 TargetUserLogins { get; set; } = new(); +} + +/// +/// Optional email delivery for a generated report, sent through Graph (app-only, +/// Mail.Send). The report file is attached. Body/subject support the +/// placeholders {ReportName}, {ClientName}, {ReportType}, {DateUtc} and {FileName}. +/// +public class ReportEmailSettings +{ + public bool Enabled { get; set; } + + /// + /// 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. + /// + public string From { get; set; } = string.Empty; + + public List To { get; set; } = new(); + public List Cc { get; set; } = new(); + + public string Subject { get; set; } = "{ClientName} β€” {ReportName}"; + + /// HTML body. Placeholders are substituted before sending. + public string Body { get; set; } = + "

Hello,

Please find attached the {ReportType} report \"{ReportName}\" for {ClientName}, generated on {DateUtc} UTC.

"; +} + +/// +/// A user-defined schedule that generates a report for a single client (profile) +/// on a recurrence. Persisted to schedules.json. +/// +public class ScheduledReport +{ + public string Id { get; set; } = Guid.NewGuid().ToString(); + + /// this schedule belongs to. + public string ProfileId { get; set; } = string.Empty; + + /// Human label shown in the UI. + public string Name { get; set; } = string.Empty; + + public ReportType Type { get; set; } + + public ScheduledReportOptions Options { get; set; } = new(); + + /// When true, run against every site in the tenant (site discovery); otherwise use . + public bool AllSites { get; set; } = true; + + public List SiteUrls { get; set; } = new(); + + public ReportMergeMode MergeMode { get; set; } = ReportMergeMode.SingleMerged; + + public ReportFormat Format { get; set; } = ReportFormat.Html; + + public RecurrenceRule Recurrence { get; set; } = new(); + + /// Optional Graph email delivery of the generated report. + 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; } +} diff --git a/Core/Models/TenantProfile.cs b/Core/Models/TenantProfile.cs index 29dcfb5..2e47cab 100644 --- a/Core/Models/TenantProfile.cs +++ b/Core/Models/TenantProfile.cs @@ -15,4 +15,41 @@ public class TenantProfile public string ClientId { get; set; } = string.Empty; 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. + + /// When true, this client uses certificate (app-only) auth for interactive and scheduled work. + public bool AppOnlyEnabled { get; set; } + + /// Client (application) ID of the app-registration used for certificate auth. May differ from . + public string AppOnlyClientId { get; set; } = string.Empty; + + /// Thumbprint of the stored certificate β€” display/verification only; the key material is stored separately. + public string AppOnlyCertThumbprint { get; set; } = string.Empty; + + /// + /// 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. + /// + public TenantProfile CloneForSite(string siteUrl) => new() + { + Id = Id, + Name = Name, + TenantUrl = siteUrl, + TenantId = TenantId, + ClientId = ClientId, + ClientLogo = ClientLogo, + AppOnlyEnabled = AppOnlyEnabled, + AppOnlyClientId = AppOnlyClientId, + AppOnlyCertThumbprint = AppOnlyCertThumbprint, + }; } diff --git a/Core/Models/UserAccessAuditResult.cs b/Core/Models/UserAccessAuditResult.cs new file mode 100644 index 0000000..ce85b76 --- /dev/null +++ b/Core/Models/UserAccessAuditResult.cs @@ -0,0 +1,12 @@ +namespace SharepointToolbox.Web.Core.Models; + +/// +/// 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). +/// +public record UserAccessAuditResult( + IReadOnlyList Entries, + int SitesScanned, + int SitesDenied, + int SitesFailed); diff --git a/Infrastructure/Auth/AppOnlyCertStore.cs b/Infrastructure/Auth/AppOnlyCertStore.cs new file mode 100644 index 0000000..470ee45 --- /dev/null +++ b/Infrastructure/Auth/AppOnlyCertStore.cs @@ -0,0 +1,70 @@ +using System.Security.Cryptography.X509Certificates; +using Microsoft.AspNetCore.DataProtection; + +namespace SharepointToolbox.Web.Infrastructure.Auth; + +/// +/// File-backed . 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. +/// +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 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 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); + } +} diff --git a/Infrastructure/Auth/AppOnlyContextFactory.cs b/Infrastructure/Auth/AppOnlyContextFactory.cs new file mode 100644 index 0000000..54f64e5 --- /dev/null +++ b/Infrastructure/Auth/AppOnlyContextFactory.cs @@ -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; + +/// +/// Certificate-based app-only client factory. Acquires tokens with +/// and injects the SharePoint bearer token +/// through CSOM's ExecutingWebRequest hook β€” the same mechanism the delegated +/// SessionManager uses, so report services see an ordinary authenticated +/// regardless of which auth model produced it. +/// +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 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 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 CreateGraphClientAsync(TenantProfile profile, CancellationToken ct = default) + { + var credential = await BuildCredentialAsync(profile, ct); + return new GraphServiceClient(credential, GraphScopes); + } + + public async Task 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 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 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"; + } +} diff --git a/Infrastructure/Auth/GraphClientFactory.cs b/Infrastructure/Auth/GraphClientFactory.cs index 7ae674a..38a099f 100644 --- a/Infrastructure/Auth/GraphClientFactory.cs +++ b/Infrastructure/Auth/GraphClientFactory.cs @@ -5,22 +5,34 @@ using SharepointToolbox.Web.Services.Session; namespace SharepointToolbox.Web.Infrastructure.Auth; -/// Delegated Graph client using OAuth2 refresh-token flow via ISessionManager. +/// +/// 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. +/// public class GraphClientFactory { private readonly ISessionCredentialStore _credentialStore; private readonly ISessionManager _sessionManager; + private readonly IAppOnlyContextFactory _appOnly; - public GraphClientFactory(ISessionCredentialStore credentialStore, ISessionManager sessionManager) + public GraphClientFactory( + ISessionCredentialStore credentialStore, + ISessionManager sessionManager, + IAppOnlyContextFactory appOnly) { _credentialStore = credentialStore; _sessionManager = sessionManager; + _appOnly = appOnly; } public async Task CreateClientAsync(TenantProfile profile) { ArgumentException.ThrowIfNullOrEmpty(profile.TenantId); + if (_appOnly.IsConfigured(profile)) + return await _appOnly.CreateGraphClientAsync(profile); + var hasTokens = await _credentialStore.HasCredentialsAsync(); if (!hasTokens) throw new InvalidOperationException( diff --git a/Infrastructure/Auth/IAppOnlyCertStore.cs b/Infrastructure/Auth/IAppOnlyCertStore.cs new file mode 100644 index 0000000..eafe6db --- /dev/null +++ b/Infrastructure/Auth/IAppOnlyCertStore.cs @@ -0,0 +1,24 @@ +using System.Security.Cryptography.X509Certificates; + +namespace SharepointToolbox.Web.Infrastructure.Auth; + +/// +/// 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. +/// +public interface IAppOnlyCertStore +{ + /// + /// 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. + /// + Task SaveAsync(string profileId, byte[] pfxBytes, string? password, CancellationToken ct = default); + + /// Loads the stored certificate (with private key) for app-only auth, or null if none. + Task LoadAsync(string profileId, CancellationToken ct = default); + + bool Exists(string profileId); + + void Delete(string profileId); +} diff --git a/Infrastructure/Auth/IAppOnlyContextFactory.cs b/Infrastructure/Auth/IAppOnlyContextFactory.cs new file mode 100644 index 0000000..39ad98c --- /dev/null +++ b/Infrastructure/Auth/IAppOnlyContextFactory.cs @@ -0,0 +1,47 @@ +using Azure.Core; +using Microsoft.Graph; +using Microsoft.SharePoint.Client; +using SharepointToolbox.Web.Core.Models; + +namespace SharepointToolbox.Web.Infrastructure.Auth; + +/// +/// 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 ), technicians operate through the +/// app identity and never sign in to SharePoint per profile. Requires +/// , an app-only client id, and a stored certificate. +/// +public interface IAppOnlyContextFactory +{ + /// + /// True when this profile can authenticate app-only without an interactive sign-in: + /// 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. + /// + bool IsConfigured(TenantProfile profile); + + /// CSOM context for a specific site, authenticated app-only. + Task CreateContextAsync(TenantProfile profile, string siteUrl, CancellationToken ct = default); + + /// Microsoft Graph client, authenticated app-only. + Task CreateGraphClientAsync(TenantProfile profile, CancellationToken ct = default); + + /// Acquires an app-only access token for an arbitrary scope (e.g. a SharePoint host or Graph). + Task GetTokenAsync(TenantProfile profile, string scope, CancellationToken ct = default); + + /// + /// Verifies the stored credentials can authenticate against the tenant root web. + /// Returns null on success, or an error message describing the failure. + /// + Task TestConnectionAsync(TenantProfile profile, CancellationToken ct = default); + + /// + /// Polls until it succeeds or + /// 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. + /// + Task WaitUntilReadyAsync(TenantProfile profile, TimeSpan timeout, CancellationToken ct = default); +} diff --git a/Infrastructure/Auth/SessionManager.cs b/Infrastructure/Auth/SessionManager.cs index 2d45aea..93838c2 100644 --- a/Infrastructure/Auth/SessionManager.cs +++ b/Infrastructure/Auth/SessionManager.cs @@ -7,23 +7,31 @@ using SharepointToolbox.Web.Services.Session; namespace SharepointToolbox.Web.Infrastructure.Auth; /// -/// Delegated session manager using OAuth2 refresh tokens. -/// Tokens come from ISessionCredentialStore (ProtectedSessionStorage β€” browser-side only). -/// Caches access tokens in-memory per scope for the duration of the Blazor circuit. +/// Session manager that resolves the auth model per profile. When a profile is configured +/// for certificate auth (), contexts are +/// 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. /// public class SessionManager : ISessionManager { private readonly ISessionCredentialStore _credentialStore; private readonly ITokenRefreshService _tokenRefresh; + private readonly IAppOnlyContextFactory _appOnly; private readonly Dictionary _contexts = new(); private readonly Dictionary _accessTokenCache = new(); private readonly SemaphoreSlim _lock = new(1, 1); - public SessionManager(ISessionCredentialStore credentialStore, ITokenRefreshService tokenRefresh) + public SessionManager( + ISessionCredentialStore credentialStore, + ITokenRefreshService tokenRefresh, + IAppOnlyContextFactory appOnly) { _credentialStore = credentialStore; _tokenRefresh = tokenRefresh; + _appOnly = appOnly; } public bool IsAuthenticated(string tenantUrl) => _contexts.ContainsKey(NormalizeUrl(tenantUrl)); @@ -62,6 +70,24 @@ public class SessionManager : ISessionManager var key = NormalizeUrl(profile.TenantUrl); 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); try { @@ -90,16 +116,7 @@ public class SessionManager : ISessionManager public async Task GetOrCreateContextAsync(string siteUrl, TenantProfile profile, CancellationToken ct = default) { - var profileForSite = new TenantProfile - { - Id = profile.Id, - Name = profile.Name, - TenantUrl = siteUrl, - TenantId = profile.TenantId, - ClientId = profile.ClientId, - ClientLogo = profile.ClientLogo, - }; - return await GetOrCreateContextAsync(profileForSite, ct); + return await GetOrCreateContextAsync(profile.CloneForSite(siteUrl), ct); } public async Task ClearSessionAsync(string tenantUrl) diff --git a/Infrastructure/Persistence/GeneratedReportRepository.cs b/Infrastructure/Persistence/GeneratedReportRepository.cs new file mode 100644 index 0000000..9bcf9d2 --- /dev/null +++ b/Infrastructure/Persistence/GeneratedReportRepository.cs @@ -0,0 +1,87 @@ +using System.Text; +using System.Text.Json; +using SharepointToolbox.Web.Core.Models; + +namespace SharepointToolbox.Web.Infrastructure.Persistence; + +/// +/// 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. +/// +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> LoadAsync() + { + if (!File.Exists(_filePath)) return Array.Empty(); + 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(json, ReadOpts); } + catch (JsonException ex) { throw new InvalidDataException($"Invalid JSON in report index: {_filePath}", ex); } + + return (IReadOnlyList?)root?.Reports ?? Array.Empty(); + } + + public async Task GetAsync(string id) + => (await LoadAsync()).FirstOrDefault(r => r.Id == id); + + public async Task> 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 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 Reports { get; set; } = new(); } +} diff --git a/Infrastructure/Persistence/ScheduledReportRepository.cs b/Infrastructure/Persistence/ScheduledReportRepository.cs new file mode 100644 index 0000000..2a77186 --- /dev/null +++ b/Infrastructure/Persistence/ScheduledReportRepository.cs @@ -0,0 +1,91 @@ +using System.Text; +using System.Text.Json; +using SharepointToolbox.Web.Core.Models; + +namespace SharepointToolbox.Web.Infrastructure.Persistence; + +/// +/// JSON-file store for definitions (schedules.json). +/// Mirrors 's atomic temp-file-then-move write. +/// Mutating helpers (/) perform the +/// read-modify-write under the same lock so concurrent callers don't clobber each other. +/// +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> LoadAsync() + { + if (!File.Exists(_filePath)) return Array.Empty(); + 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(json, ReadOpts); } + catch (JsonException ex) { throw new InvalidDataException($"Invalid JSON in schedules: {_filePath}", ex); } + + return (IReadOnlyList?)root?.Schedules ?? Array.Empty(); + } + + public async Task> LoadForProfileAsync(string profileId) + { + var all = await LoadAsync(); + return all.Where(s => s.ProfileId == profileId).ToList(); + } + + public async Task SaveAllAsync(IReadOnlyList 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 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 Schedules { get; set; } = new(); } +} diff --git a/Localization/Strings.fr.resx b/Localization/Strings.fr.resx index 9d94cac..0e5b099 100644 --- a/Localization/Strings.fr.resx +++ b/Localization/Strings.fr.resx @@ -924,6 +924,33 @@ Cet onglet fait l'inverse : vous sΓ©lectionnez un ou plusieurs utilisateurs et i Γ‰levΓ© + + Par site + + + Tableau + + + Sites auxquels le(s) utilisateur(s) sΓ©lectionnΓ©(s) ont accΓ¨s. Cliquez sur un site pour afficher le dΓ©tail des permissions. + + + Facultatif β€” laissez vide pour analyser tous les sites du locataire. + + + DΓ©couverte de tous les sites du locataire… + + + Impossible de lister les sites du locataire : {0} + + + {0} site(s) analysΓ©(s) + + + {0} ignorΓ©(s) (aucun accΓ¨s) + + + {0} en Γ©chec + Inclure les autorisations hΓ©ritΓ©es @@ -1326,6 +1353,9 @@ Cet onglet fait l'inverse : vous sΓ©lectionnez un ou plusieurs utilisateurs et i Reconnecter + + identitΓ© application + Rechercher… @@ -1497,6 +1527,12 @@ Cet onglet fait l'inverse : vous sΓ©lectionnez un ou plusieurs utilisateurs et i Application inscrite. VΓ©rifiez et enregistrez le profil. + + Application inscrite. Propagation du certificat et du consentement en cours… + + + 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. + Demande d'un code de connexion… @@ -1887,4 +1923,6 @@ Cet onglet fait l'inverse : vous sΓ©lectionnez un ou plusieurs utilisateurs et i Membre = un compte interne Γ  votre organisation ; InvitΓ© = un utilisateur externe invitΓ© d'une autre organisation. 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. Les groupes de permissions du site (PropriΓ©taires, Membres, Visiteurs) et leurs membres, afin de recrΓ©er la mΓͺme configuration d'accΓ¨s. + Rapports planifiΓ©s + Rapports diff --git a/Localization/Strings.resx b/Localization/Strings.resx index 115db56..499f637 100644 --- a/Localization/Strings.resx +++ b/Localization/Strings.resx @@ -924,6 +924,33 @@ This tab does the reverse: you select one or more users and it finds every objec High + + By site + + + Table + + + Sites the selected user(s) can access. Click a site to reveal the permission detail. + + + Optional β€” leave empty to scan every site in the tenant. + + + Discovering all sites in the tenant… + + + Could not list tenant sites: {0} + + + {0} site(s) scanned + + + {0} skipped (no access) + + + {0} failed + Include inherited @@ -1326,6 +1353,9 @@ This tab does the reverse: you select one or more users and it finds every objec Reconnect + + app identity + Search… @@ -1497,6 +1527,12 @@ This tab does the reverse: you select one or more users and it finds every objec App registered. Review and Save the profile. + + App registered. Waiting for certificate and consent to propagate… + + + App registered, but app-only auth is not ready yet ({0}). It may take a few minutes; Save and use Test connection shortly. + Requesting a sign-in code… @@ -1887,4 +1923,6 @@ This tab does the reverse: you select one or more users and it finds every objec Member = an account inside your organization; Guest = an external user invited from another organization. Capturing saves a site's structure (libraries, folders, permission groups) as a reusable template you can later apply to create new sites. The site's permission groups (Owners, Members, Visitors) and their members, so the same access setup can be recreated. + Scheduled Reports + Reports diff --git a/Program.cs b/Program.cs index 7d43f01..be71f2f 100644 --- a/Program.cs +++ b/Program.cs @@ -3,6 +3,7 @@ using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Authentication.Cookies; using Microsoft.AspNetCore.Authentication.OpenIdConnect; using Microsoft.AspNetCore.Antiforgery; +using Microsoft.AspNetCore.DataProtection; using Microsoft.AspNetCore.Identity; using Microsoft.Extensions.Options; using Microsoft.IdentityModel.Protocols.OpenIdConnect; @@ -17,6 +18,7 @@ using SharepointToolbox.Web.Services.Audit; using SharepointToolbox.Web.Services.Auth; using SharepointToolbox.Web.Services.Export; using SharepointToolbox.Web.Services.OAuth; +using SharepointToolbox.Web.Services.Reports; using SharepointToolbox.Web.Services.Session; var builder = WebApplication.CreateBuilder(args); @@ -110,11 +112,14 @@ builder.Services.AddHttpClient("oauth"); builder.Services.Configure(builder.Configuration.GetSection("ClientConnect")); // ── App config ──────────────────────────────────────────────────────────────── +var certsFolder = Path.Combine(dataFolder, "appcerts"); builder.Services.Configure(opt => { opt.DataFolder = dataFolder; opt.ExportsFolder = Path.Combine(dataFolder, "exports"); + opt.CertsFolder = certsFolder; Directory.CreateDirectory(opt.ExportsFolder); + Directory.CreateDirectory(opt.CertsFolder); }); // ── 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 UserRepository(Path.Combine(dataFolder, "users.json"))); 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(sp => + new AppOnlyCertStore(certsFolder, sp.GetRequiredService())); +builder.Services.AddSingleton(); // ── Auth infrastructure ─────────────────────────────────────────────────────── builder.Services.AddSingleton, PasswordHasher>(); @@ -131,6 +143,7 @@ builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddHttpClient(); builder.Services.AddHttpClient(); +builder.Services.AddSingleton(); builder.Services.AddScoped(); // ── User session (Scoped = one per Blazor circuit = one per browser tab) ───── @@ -177,6 +190,12 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); +// ── Scheduled reports (background generation) ───────────────────────────────── +builder.Services.AddSingleton(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddHostedService(); + var app = builder.Build(); if (!app.Environment.IsDevelopment()) @@ -301,6 +320,21 @@ app.MapGet("/export/download/{fileName}", async (string fileName, IOptions 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 ──────────────────────────────────────────────────────── app.MapGet("/audit/export", async (AuditRepository auditRepo, HttpContext ctx) => { diff --git a/Services/Auth/AppRegistrationService.cs b/Services/Auth/AppRegistrationService.cs index ee75fb2..36fbac1 100644 --- a/Services/Auth/AppRegistrationService.cs +++ b/Services/Auth/AppRegistrationService.cs @@ -24,6 +24,22 @@ public class AppRegistrationService : IAppRegistrationService "AllSites.FullControl", // CSOM β€” site permissions, content, admin operations ]; + // Graph APPLICATION permissions (app roles) for certificate (app-only) auth. + private static readonly string[] GraphAppRoles = + [ + "User.Read.All", + "Group.ReadWrite.All", + "Directory.Read.All", // expand M365/AAD group membership in the user-access audit (SharePointGroupResolver) + "Sites.FullControl.All", + "Mail.Send", // app-only sendMail for emailed scheduled reports + ]; + + // SharePoint APPLICATION permission (app role) for certificate (app-only) CSOM. + private static readonly string[] SpAppRoles = + [ + "Sites.FullControl.All", + ]; + private readonly HttpClient _http; public AppRegistrationService(HttpClient http) { _http = http; } @@ -32,103 +48,145 @@ public class AppRegistrationService : IAppRegistrationService string adminAccessToken, string tenantName, string redirectUri, + CertProvisioningResult? appOnlyCert = null, CancellationToken ct = default) { _http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", adminAccessToken); - // 1. Resolve Graph + SharePoint service principals in the target tenant - var (graphSpId, graphPermIds) = await ResolveServicePrincipalAsync(GraphAppId, GraphScopes, ct); - var (spSpId, spPermIds) = await ResolveServicePrincipalAsync(SharePointAppId, SpScopes, ct); + bool wantsAppOnly = appOnlyCert is not null; - // 2. Create app registration - var appBody = new + // 1. Resolve Graph + SharePoint service principals + the permission ids we need + var graph = await ResolveServicePrincipalAsync(GraphAppId, GraphScopes, GraphAppRoles, ct); + var sp = await ResolveServicePrincipalAsync(SharePointAppId, SpScopes, SpAppRoles, ct); + + // 2. Create app registration (delegated scopes always; application roles when app-only) + var appBody = new Dictionary { - displayName = $"SP Toolbox β€” {tenantName}", - signInAudience = "AzureADMyOrg", - isFallbackPublicClient = true, + ["displayName"] = $"SP Toolbox β€” {tenantName}", + ["signInAudience"] = "AzureADMyOrg", + ["isFallbackPublicClient"] = true, // Register the redirect under the PUBLIC client platform so the connect // flow can redeem the auth code with PKCE only (no client secret). A // redirect under `web` makes Entra treat the app as confidential and the // token exchange fails with AADSTS7000218 (secret required). - publicClient = new { redirectUris = new[] { redirectUri } }, - requiredResourceAccess = new[] + ["publicClient"] = new { redirectUris = new[] { redirectUri } }, + ["requiredResourceAccess"] = new[] { new { resourceAppId = GraphAppId, - resourceAccess = graphPermIds.Select(id => new { id, type = "Scope" }).ToArray(), + resourceAccess = ResourceAccess(graph.ScopeIds, wantsAppOnly ? graph.AppRoleIds : []), }, new { resourceAppId = SharePointAppId, - resourceAccess = spPermIds.Select(id => new { id, type = "Scope" }).ToArray(), + resourceAccess = ResourceAccess(sp.ScopeIds, wantsAppOnly ? sp.AppRoleIds : []), }, }, }; - var appJson = await PostGraphAsync("https://graph.microsoft.com/v1.0/applications", - appBody, ct); + // Attach the certificate as a sign-in credential so app-only token requests succeed. + if (wantsAppOnly) + appBody["keyCredentials"] = new[] { BuildKeyCredential(appOnlyCert!, tenantName) }; + + var appJson = await PostGraphAsync("https://graph.microsoft.com/v1.0/applications", appBody, ct); var clientId = appJson.GetProperty("appId").GetString()!; // 3. Create service principal for the new app - var spJson = await PostGraphAsync("https://graph.microsoft.com/v1.0/servicePrincipals", + var spJson = await PostGraphAsync("https://graph.microsoft.com/v1.0/servicePrincipals", new { appId = clientId }, ct); var newSpId = spJson.GetProperty("id").GetString()!; - // 4. Grant org-wide admin consent for Graph - await PostGraphAsync("https://graph.microsoft.com/v1.0/oauth2PermissionGrants", - new - { - clientId = newSpId, - consentType = "AllPrincipals", - resourceId = graphSpId, - scope = string.Join(" ", GraphScopes), - }, ct); + // 4. Grant org-wide admin consent for Graph + SharePoint delegated scopes + await GrantDelegatedConsentAsync(newSpId, graph.SpObjectId, GraphScopes, ct); + await GrantDelegatedConsentAsync(newSpId, sp.SpObjectId, SpScopes, ct); - // 5. Grant org-wide admin consent for SharePoint - await PostGraphAsync("https://graph.microsoft.com/v1.0/oauth2PermissionGrants", - new - { - clientId = newSpId, - consentType = "AllPrincipals", - resourceId = spSpId, - scope = string.Join(" ", SpScopes), - }, ct); + // 5. Grant admin consent for application permissions (app roles) when app-only + if (wantsAppOnly) + { + await GrantAppRolesAsync(newSpId, graph.SpObjectId, graph.AppRoleIds, ct); + await GrantAppRolesAsync(newSpId, sp.SpObjectId, sp.AppRoleIds, ct); + } return clientId; } - // Returns (servicePrincipalObjectId, [permissionIds matching requested scopes]) - private async Task<(string SpObjectId, string[] PermissionIds)> ResolveServicePrincipalAsync( - string appId, string[] scopeNames, CancellationToken ct) + private static object BuildKeyCredential(CertProvisioningResult cert, string tenantName) => new + { + type = "AsymmetricX509Cert", + usage = "Verify", + key = cert.PublicCertBase64, + displayName = $"CN=SP Toolbox β€” {tenantName}", + startDateTime = cert.NotBefore.UtcDateTime.ToString("o"), + endDateTime = cert.NotAfter.UtcDateTime.ToString("o"), + }; + + private static object[] ResourceAccess(string[] scopeIds, string[] appRoleIds) + { + var list = new List(scopeIds.Length + appRoleIds.Length); + list.AddRange(scopeIds.Select(id => new { id, type = "Scope" })); + list.AddRange(appRoleIds.Select(id => new { id, type = "Role" })); + return list.ToArray(); + } + + private async Task GrantDelegatedConsentAsync(string clientSpId, string resourceSpId, string[] scopes, CancellationToken ct) + { + await PostGraphAsync("https://graph.microsoft.com/v1.0/oauth2PermissionGrants", + new + { + clientId = clientSpId, + consentType = "AllPrincipals", + resourceId = resourceSpId, + scope = string.Join(" ", scopes), + }, ct); + } + + private async Task GrantAppRolesAsync(string clientSpId, string resourceSpId, string[] appRoleIds, CancellationToken ct) + { + foreach (var appRoleId in appRoleIds) + { + await PostGraphAsync( + $"https://graph.microsoft.com/v1.0/servicePrincipals/{clientSpId}/appRoleAssignments", + new { principalId = clientSpId, resourceId = resourceSpId, appRoleId }, ct); + } + } + + // Returns the SP object id plus the ids of the requested delegated scopes and application roles. + private async Task<(string SpObjectId, string[] ScopeIds, string[] AppRoleIds)> ResolveServicePrincipalAsync( + string appId, string[] scopeNames, string[] roleNames, CancellationToken ct) { var url = $"https://graph.microsoft.com/v1.0/servicePrincipals" + - $"?$filter=appId eq '{appId}'&$select=id,oauth2PermissionScopes"; + $"?$filter=appId eq '{appId}'&$select=id,oauth2PermissionScopes,appRoles"; var resp = await _http.GetAsync(url, ct); var json = await resp.Content.ReadAsStringAsync(ct); resp.EnsureSuccessStatusCode(); - using var doc = JsonDocument.Parse(json); - var values = doc.RootElement.GetProperty("value"); - var sp = values.EnumerateArray().First(); - var spId = sp.GetProperty("id").GetString()!; - var allScopes = sp.GetProperty("oauth2PermissionScopes"); + using var doc = JsonDocument.Parse(json); + var sp = doc.RootElement.GetProperty("value").EnumerateArray().First(); + var spId = sp.GetProperty("id").GetString()!; + var scopeIds = MatchByValue(sp.GetProperty("oauth2PermissionScopes"), scopeNames); + var roleIds = MatchByValue(sp.GetProperty("appRoles"), roleNames); + + return (spId, scopeIds, roleIds); + } + + private static string[] MatchByValue(JsonElement entries, string[] wantedValues) + { var ids = new List(); - foreach (var scope in allScopes.EnumerateArray()) + foreach (var entry in entries.EnumerateArray()) { - var value = scope.GetProperty("value").GetString(); - if (scopeNames.Contains(value, StringComparer.OrdinalIgnoreCase)) - ids.Add(scope.GetProperty("id").GetString()!); + var value = entry.GetProperty("value").GetString(); + if (wantedValues.Contains(value, StringComparer.OrdinalIgnoreCase)) + ids.Add(entry.GetProperty("id").GetString()!); } - - return (spId, ids.ToArray()); + return ids.ToArray(); } private async Task PostGraphAsync(string url, object body, CancellationToken ct) { - var content = new StringContent( + var content = new StringContent( JsonSerializer.Serialize(body, new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase }), Encoding.UTF8, "application/json"); diff --git a/Services/Auth/CertProvisioningService.cs b/Services/Auth/CertProvisioningService.cs new file mode 100644 index 0000000..4d4d79c --- /dev/null +++ b/Services/Auth/CertProvisioningService.cs @@ -0,0 +1,57 @@ +using System.Security.Cryptography; +using System.Security.Cryptography.X509Certificates; +using SharepointToolbox.Web.Infrastructure.Auth; + +namespace SharepointToolbox.Web.Services.Auth; + +/// +/// Creates a 2048-bit RSA self-signed certificate valid for two years, persists its private +/// key (PFX) through , and returns the public certificate so +/// the caller can attach it to the Entra app registration as a sign-in credential. +/// +public class CertProvisioningService : ICertProvisioningService +{ + private readonly IAppOnlyCertStore _certStore; + + public CertProvisioningService(IAppOnlyCertStore certStore) { _certStore = certStore; } + + public async Task 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; + } +} diff --git a/Services/Auth/IAppRegistrationService.cs b/Services/Auth/IAppRegistrationService.cs index 8f4cce6..b829a40 100644 --- a/Services/Auth/IAppRegistrationService.cs +++ b/Services/Auth/IAppRegistrationService.cs @@ -4,13 +4,20 @@ public interface IAppRegistrationService { /// /// Creates an Entra ID app registration in the target tenant using a delegated admin token - /// (requires Application.ReadWrite.All + DelegatedPermissionGrant.ReadWrite.All scope). - /// Grants org-wide admin consent for SharePoint + Graph delegated permissions. - /// Returns the new app's client ID (appId). + /// (requires Application.ReadWrite.All + DelegatedPermissionGrant.ReadWrite.All + + /// AppRoleAssignment.ReadWrite.All scope). Grants org-wide admin consent for SharePoint + Graph + /// delegated permissions (fallback sign-in flow). + /// + /// When is supplied, the registration is also provisioned for + /// certificate (app-only) auth: the public certificate is attached as a sign-in credential, + /// SharePoint + Graph application 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). /// Task CreateAsync( string adminAccessToken, string tenantName, string redirectUri, + CertProvisioningResult? appOnlyCert = null, CancellationToken ct = default); } diff --git a/Services/Auth/ICertProvisioningService.cs b/Services/Auth/ICertProvisioningService.cs new file mode 100644 index 0000000..4524e6f --- /dev/null +++ b/Services/Auth/ICertProvisioningService.cs @@ -0,0 +1,25 @@ +namespace SharepointToolbox.Web.Services.Auth; + +/// +/// 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. +/// +/// SHA-1 thumbprint of the generated certificate. +/// Base64 of the DER-encoded public certificate (Graph keyCredential.key). +/// Validity start (UTC). +/// Validity end (UTC). +public record CertProvisioningResult( + string Thumbprint, + string PublicCertBase64, + DateTimeOffset NotBefore, + DateTimeOffset NotAfter); + +/// +/// 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. +/// +public interface ICertProvisioningService +{ + Task GenerateAndStoreAsync(string profileId, string subjectName, CancellationToken ct = default); +} diff --git a/Services/IUserAccessAuditService.cs b/Services/IUserAccessAuditService.cs index 6fafa7f..f0ebb6d 100644 --- a/Services/IUserAccessAuditService.cs +++ b/Services/IUserAccessAuditService.cs @@ -4,7 +4,7 @@ namespace SharepointToolbox.Web.Services; public interface IUserAccessAuditService { - Task> AuditUsersAsync( + Task AuditUsersAsync( ISessionManager sessionManager, TenantProfile currentProfile, IReadOnlyList targetUserLogins, diff --git a/Services/Reports/IReportMailService.cs b/Services/Reports/IReportMailService.cs new file mode 100644 index 0000000..28d1427 --- /dev/null +++ b/Services/Reports/IReportMailService.cs @@ -0,0 +1,21 @@ +using SharepointToolbox.Web.Core.Models; + +namespace SharepointToolbox.Web.Services.Reports; + +/// Sends a generated report as a Graph email (app-only, Mail.Send). +public interface IReportMailService +{ + /// + /// Sends as an attachment to the recipients in + /// , sending AS . + /// Subject/body placeholders are resolved from the schedule and client. + /// + Task SendAsync( + TenantProfile profile, + ScheduledReport schedule, + ReportEmailSettings settings, + string fileName, + string mime, + byte[] bytes, + CancellationToken ct = default); +} diff --git a/Services/Reports/IScheduledReportRunner.cs b/Services/Reports/IScheduledReportRunner.cs new file mode 100644 index 0000000..7062cb6 --- /dev/null +++ b/Services/Reports/IScheduledReportRunner.cs @@ -0,0 +1,14 @@ +using SharepointToolbox.Web.Core.Models; + +namespace SharepointToolbox.Web.Services.Reports; + +public interface IScheduledReportRunner +{ + /// + /// 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 with . + /// + Task RunAsync(ScheduledReport schedule, CancellationToken ct = default); +} diff --git a/Services/Reports/ReportMailService.cs b/Services/Reports/ReportMailService.cs new file mode 100644 index 0000000..4de8372 --- /dev/null +++ b/Services/Reports/ReportMailService.cs @@ -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; + +/// +/// 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 +/// /users/{From}/sendMail β€” the configured sender mailbox must exist in the +/// tenant and the app registration must hold the Mail.Send application role. +/// +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 + { + 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 CleanAddresses(IEnumerable 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")); +} diff --git a/Services/Reports/ScheduledReportHostedService.cs b/Services/Reports/ScheduledReportHostedService.cs new file mode 100644 index 0000000..13eb40c --- /dev/null +++ b/Services/Reports/ScheduledReportHostedService.cs @@ -0,0 +1,130 @@ +using Microsoft.Extensions.Logging; +using SharepointToolbox.Web.Core.Models; +using SharepointToolbox.Web.Infrastructure.Persistence; + +namespace SharepointToolbox.Web.Services.Reports; + +/// +/// Background scheduler. Every it loads the schedule +/// definitions, runs any whose 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. +/// +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 _log; + + public ScheduledReportHostedService( + IServiceScopeFactory scopeFactory, + ScheduledReportRepository repo, + ScheduledRunCoordinator coordinator, + ILogger 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 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(); + 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); + } + } +} diff --git a/Services/Reports/ScheduledReportRunner.cs b/Services/Reports/ScheduledReportRunner.cs new file mode 100644 index 0000000..137f5cf --- /dev/null +++ b/Services/Reports/ScheduledReportRunner.cs @@ -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; + +/// +/// 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 , so they are reused +/// verbatim; the only difference from the interactive pages is that the context is +/// produced by instead of the delegated session. +/// +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 _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 cfg, + ILogger 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 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> 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 GenerateAsync( + TenantProfile profile, ScheduledReport schedule, IReadOnlyList sites, + ReportBranding branding, string ts, CancellationToken ct) + { + var o = schedule.Options; + var progress = new Progress(); + 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 Results)>> ScanSites( + Func>> scan) + { + var bySite = new List<(string, IReadOnlyList)>(); + 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((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((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((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((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(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((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 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(), + Details = $"{status}: {schedule.Name} β€” {details}", + UserEmail = "system", + UserDisplay = "Scheduler", + UserRole = UserRole.Admin + }); +} diff --git a/Services/Reports/ScheduledRunCoordinator.cs b/Services/Reports/ScheduledRunCoordinator.cs new file mode 100644 index 0000000..9bf0538 --- /dev/null +++ b/Services/Reports/ScheduledRunCoordinator.cs @@ -0,0 +1,78 @@ +using System.Collections.Concurrent; + +namespace SharepointToolbox.Web.Services.Reports; + +/// +/// Process-wide coordinator for scheduled-report execution. Covers two operator needs: +/// β€’ Cancel an in-flight run β€” every active run registers a linked +/// 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". +/// β€’ Pause future runs β€” a global flag the background scheduler honours, +/// letting an admin suspend all cadence-triggered runs at once without toggling +/// each schedule's Enabled flag. +/// +/// In-memory and singleton. The pause flag does NOT survive a process restart (a +/// restart resumes the scheduler); per-schedule Enabled flags persist and are +/// the durable way to keep a schedule off. +/// +public sealed class ScheduledRunCoordinator +{ + private readonly ConcurrentDictionary _active = new(); + private volatile bool _paused; + + /// True while the scheduler is globally paused (no schedules fire). + public bool IsPaused => _paused; + + public void Pause() => _paused = true; + public void Resume() => _paused = false; + + /// True while a run is registered for this schedule id. + public bool IsRunning(string scheduleId) => _active.ContainsKey(scheduleId); + + /// Snapshot of schedule ids with a run in progress. + public IReadOnlyCollection RunningIds => _active.Keys.ToArray(); + + /// + /// Registers a run for and returns a token that trips + /// when either the caller's token (e.g. app shutdown) or a + /// / call fires. Returns null if a + /// run is already registered for this schedule β€” callers must skip to avoid overlap. + /// Always pair a non-null return with in a finally. + /// + public CancellationToken? TryBegin(string scheduleId, CancellationToken linked) + { + var cts = CancellationTokenSource.CreateLinkedTokenSource(linked); + if (!_active.TryAdd(scheduleId, cts)) { cts.Dispose(); return null; } + return cts.Token; + } + + /// Deregisters and disposes the run for this schedule id. + public void Complete(string scheduleId) + { + if (_active.TryRemove(scheduleId, out var cts)) cts.Dispose(); + } + + /// Signals cancellation to the run for this schedule id. Returns false if none. + 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; + } + + /// Signals cancellation to every run in progress. Returns the count signalled. + public int CancelAll() + { + int n = 0; + foreach (var cts in _active.Values) + { + try { cts.Cancel(); n++; } + catch (ObjectDisposedException) { /* completed concurrently */ } + } + return n; + } +} diff --git a/Services/SharePointGroupResolver.cs b/Services/SharePointGroupResolver.cs index af4a52b..6d182c0 100644 --- a/Services/SharePointGroupResolver.cs +++ b/Services/SharePointGroupResolver.cs @@ -63,7 +63,11 @@ public class SharePointGroupResolver : ISharePointGroupResolver { graphClient ??= await _graphClientFactory.CreateClientAsync(profile); 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); } else @@ -83,10 +87,27 @@ public class SharePointGroupResolver : ISharePointGroupResolver return result; } + // Group principals that must be expanded via Graph: + // c:0t.c|tenant| β†’ AAD security group + // c:0o.c|federateddirectoryclaimprovider| β†’ M365 group members (group-connected/Teams sites) + // c:0o.c|federateddirectoryclaimprovider|_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) => - 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)..]; private static async Task> ResolveAadGroupAsync( @@ -122,4 +143,40 @@ public class SharePointGroupResolver : ISharePointGroupResolver return Enumerable.Empty(); } } + + // 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> 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(); + + var owners = new List(); + var iter = PageIterator.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(); + } + } } diff --git a/Services/SiteDiscoveryService.cs b/Services/SiteDiscoveryService.cs index fb57d9d..506f29d 100644 --- a/Services/SiteDiscoveryService.cs +++ b/Services/SiteDiscoveryService.cs @@ -1,31 +1,38 @@ -using Microsoft.Online.SharePoint.TenantAdministration; -using Microsoft.SharePoint.Client; using SharepointToolbox.Web.Core.Helpers; using SharepointToolbox.Web.Core.Models; +using SharepointToolbox.Web.Infrastructure.Auth; using SharepointToolbox.Web.Services.Session; namespace SharepointToolbox.Web.Services; /// -/// Delegated CSOM implementation of . +/// Enumerates every site collection in a tenant via the SharePoint tenant-admin endpoint +/// (Tenant.GetSitePropertiesFromSharePointByFilters), 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 -/// (Tenant.GetSitePropertiesFromSharePointByFilters), paging through all -/// results. Requires the signed-in user to be a SharePoint administrator. +/// β€’ Certificate (app-only) profiles build the admin context through the cert factory β€” the +/// same path the background report scheduler uses (), which +/// relies only on the SharePoint Sites.FullControl.All application permission the cert +/// app already holds. (The earlier Graph /sites/getAllSites path was dropped: it needs +/// a separate Graph Sites.Read.All 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 /sites?search=* endpoint was deliberately abandoned: it ranks -/// by relevance and is capped server-side, so it silently dropped sites and -/// returned varying counts run-to-run. /sites/getAllSites 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. +/// The Graph /sites?search=* endpoint was deliberately abandoned for both: it ranks by +/// relevance and is capped server-side, silently dropping sites and returning varying counts. /// public class SiteDiscoveryService : ISiteDiscoveryService { private readonly ISessionManager _sessionManager; + private readonly IAppOnlyContextFactory _appOnly; - public SiteDiscoveryService(ISessionManager sessionManager) + public SiteDiscoveryService( + ISessionManager sessionManager, + IAppOnlyContextFactory appOnly) { _sessionManager = sessionManager; + _appOnly = appOnly; } public async Task> SearchSitesAsync( @@ -35,103 +42,19 @@ public class SiteDiscoveryService : ISiteDiscoveryService { ArgumentException.ThrowIfNullOrEmpty(profile.TenantUrl); - // Site enumeration only exists on the tenant admin endpoint. - var adminProfile = new TenantProfile + var adminUrl = TenantSiteEnumerator.BuildAdminUrl(profile.TenantUrl); + + // 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, - Name = profile.Name, - 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(); - 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; + using var adminCtx = await _appOnly.CreateContextAsync(profile, adminUrl, ct); + return await TenantSiteEnumerator.EnumerateAsync(adminCtx, ct); } - 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 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 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}"; + // Delegated profiles: enumeration only exists on the tenant admin endpoint. + var adminProfile = profile.CloneForSite(adminUrl); + var ctx = await _sessionManager.GetOrCreateContextAsync(adminProfile, ct); + return await TenantSiteEnumerator.EnumerateAsync(ctx, ct); } } diff --git a/Services/UserAccessAuditService.cs b/Services/UserAccessAuditService.cs index 690f297..6ec8e7f 100644 --- a/Services/UserAccessAuditService.cs +++ b/Services/UserAccessAuditService.cs @@ -7,19 +7,27 @@ public class UserAccessAuditService : IUserAccessAuditService { private readonly IPermissionsService _permissionsService; private readonly IElevationCoordinator _elevation; + private readonly ISharePointGroupResolver _groupResolver; private static readonly HashSet HighPrivilegeLevels = new(StringComparer.OrdinalIgnoreCase) { "Full Control", "Site Collection Administrator" }; - public UserAccessAuditService(IPermissionsService permissionsService, IElevationCoordinator elevation) + private static readonly IReadOnlyDictionary> NoGroupMembers = + new Dictionary>(); + + public UserAccessAuditService( + IPermissionsService permissionsService, + IElevationCoordinator elevation, + ISharePointGroupResolver groupResolver) { _permissionsService = permissionsService; _elevation = elevation; + _groupResolver = groupResolver; } - public async Task> AuditUsersAsync( + public async Task AuditUsersAsync( ISessionManager sessionManager, TenantProfile currentProfile, IReadOnlyList targetUserLogins, @@ -32,10 +40,16 @@ public class UserAccessAuditService : IUserAccessAuditService .Select(l => l.Trim().ToLowerInvariant()) .Where(l => l.Length > 0).ToHashSet(); - if (targets.Count == 0) return Array.Empty(); + if (targets.Count == 0) return new UserAccessAuditResult(Array.Empty(), 0, 0, 0); var allEntries = new List(); + // 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++) { ct.ThrowIfCancellationRequested(); @@ -43,64 +57,157 @@ public class UserAccessAuditService : IUserAccessAuditService progress.Report(new OperationProgress(i, sites.Count, $"Scanning site {i + 1}/{sites.Count}: {site.Title}...")); - var profile = new TenantProfile - { - TenantUrl = site.Url, - TenantId = currentProfile.TenantId, - ClientId = currentProfile.ClientId, - Name = site.Title - }; + var profile = currentProfile.CloneForSite(site.Url); + profile.Name = site.Title; - // Auto-elevates site-collection admin ownership and retries when a scan is denied, - // if the AutoTakeOwnership setting is enabled (otherwise the access-denied propagates). - var permEntries = await _elevation.RunAsync(async c => + try { - var ctx = await sessionManager.GetOrCreateContextAsync(profile, c); - return await _permissionsService.ScanSiteAsync(ctx, options, progress, c); - }, ct); + // Auto-elevates site-collection admin ownership and retries when a scan is denied, + // 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); + 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, - $"Audit complete: {allEntries.Count} access entries found.")); - return allEntries; + var summary = $"Audit complete: {allEntries.Count} access entries found."; + if (deniedSites > 0) summary += $" {deniedSites} site(s) skipped (no access)."; + 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 TransformEntries( - IReadOnlyList permEntries, HashSet targets, SiteInfo site) + /// + /// Resolves every SharePoint group referenced by 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. + /// + public static async Task>> ResolveGroupMembersAsync( + ISharePointGroupResolver resolver, + Microsoft.SharePoint.Client.ClientContext ctx, + TenantProfile profile, + IReadOnlyList 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; + } + } + + /// + /// Projects raw permission entries for one site into per-user access entries, keeping only + /// rows touching one of (substring match on login). Direct user + /// grants match the principal's own login; SharePoint-group grants match against the group's + /// expanded membership in (see ), + /// 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. + /// + internal static IEnumerable TransformEntries( + IReadOnlyList permEntries, HashSet targets, SiteInfo site, + IReadOnlyDictionary>? groupMembers = null) { 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); + 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++) { var login = logins[u].Trim(); var loginLower = login.ToLowerInvariant(); var displayName = u < names.Length ? names[u].Trim() : login; - bool isTarget = targets.Any(t => loginLower.Contains(t) || t.Contains(loginLower)); - if (!isTarget) continue; - - var accessType = !entry.HasUniquePermissions ? AccessType.Inherited - : entry.GrantedThrough.StartsWith("SharePoint Group:", StringComparison.OrdinalIgnoreCase) - ? AccessType.Group : AccessType.Direct; + if (!targets.Any(t => loginLower.Contains(t) || t.Contains(loginLower))) continue; + var accessType = entry.HasUniquePermissions ? AccessType.Direct : AccessType.Inherited; foreach (var level in permLevels) { var trimmed = level.Trim(); if (string.IsNullOrEmpty(trimmed)) continue; - yield return new UserAccessEntry( - 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); + yield return Build(displayName, login, accessType, trimmed); } } } diff --git a/wwwroot/app.css b/wwwroot/app.css index 8c83438..76b6f26 100644 --- a/wwwroot/app.css +++ b/wwwroot/app.css @@ -1,35 +1,35 @@ :root { --sidebar-width: 248px; --sidebar-collapsed-width: 78px; - --bg: #eef0f7; - --page-bg: #eef0f7; + --bg: #eff6ff; + --page-bg: #eff6ff; --sidebar-bg: #ffffff; --sidebar-text: #3f4254; --sidebar-muted: #8a8d9b; - --sidebar-hover: #f2f3f9; - --sidebar-accent: #5b5bd6; - --sidebar-active: #5b5bd6; + --sidebar-hover: #eff6ff; + --sidebar-accent: #006cd2; + --sidebar-active: #006cd2; --card-bg: #fff; - --surface-hover: #f2f3f9; - --th-bg: #f4f5fb; + --surface-hover: #eff6ff; + --th-bg: #eff6ff; --input-bg: #fff; - --border: #e6e7f0; - --accent: #5b5bd6; - --accent-dark: #4a4ac0; - --accent-soft: rgba(91,91,214,.12); + --border: #d8e6f5; + --accent: #006cd2; + --accent-dark: #092c55; + --accent-soft: rgba(0,108,210,.12); --danger: #d13438; --success: #107c10; - --warn: #797673; - --text: #323130; - --text-muted: #605e5c; - --surface-2: #2d2d4e; + --warn: #fea20a; + --text: #092c55; + --text-muted: #5a6b80; + --surface-2: #092c55; --font: 'Segoe UI', system-ui, sans-serif; /* shape + depth β€” match sidebar */ --radius-lg: 20px; --radius-md: 12px; --radius-sm: 10px; --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; } @@ -133,7 +133,7 @@ body { .nav-item:hover { background: var(--sidebar-hover); } .nav-item.active { 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-label { overflow: hidden; text-overflow: ellipsis; } @@ -371,9 +371,26 @@ body { 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-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 ── */ .folder-builder { display: flex; flex-direction: column; gap: 6px; padding: 10px; border: 1px solid var(--border); border-radius: var(--radius-sm); background: var(--surface); }