Files
kawa 6d9c79ad5a Add scheduled reports + app-only cert auth; fix tenant-wide user-access audit
Feature work:
- Certificate (app-only) auth per profile: cert store, context/Graph client
  factories, automated app-registration provisioning (delegated + application
  permissions, admin consent), and a SessionManager seam that resolves the auth
  model per profile.
- Scheduled reports: repositories, hosted service/runner/coordinator, report
  pages, and email delivery (app-only Mail.Send).
- Tenant-wide user-access audit when no site is selected.

Audit fixes:
- Site enumeration: app-only discovery used Graph getAllSites (needs Graph
  Sites.Read.All the cert app lacks) and silently returned empty. Switched to
  the admin-host CSOM TenantSiteEnumerator, matching the scheduler; both auth
  models now share one enumeration path.
- Group expansion: the scan records a SharePoint group as a single principal, so
  user-centric audits found nothing for group-granted access. Resolve group
  membership (shared by audit + scheduler) and attribute it to the target user.
- M365 group claims: the resolver only recognized AAD security groups
  (c:0t.c|). Group-connected/Teams sites grant via the M365 group claim
  (c:0o.c|…|<guid>[_o]); now expanded too, resolving owners for the "_o" claim.
- Provision Directory.Read.All as an application permission so M365/AAD group
  expansion works under the cert identity.

Also: ignore data/appcerts/ (encrypted certificate key material).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 17:55:28 +02:00

140 lines
7.1 KiB
Plaintext
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
@page "/duplicates"
@attribute [Authorize]
@inject IUserSessionService Session
@inject ISessionManager SessionMgr
@inject IElevationCoordinator Elevation
@inject IDuplicatesService DupSvc
@inject DuplicatesCsvExportService CsvExport
@inject DuplicatesHtmlExportService HtmlExport
@inject WebExportService WebExport
@inject IAuditService Audit
@inject TranslationSource T
@rendermode InteractiveServer
<h1 class="page-title">@T["duplicates.page.title"]</h1>
@if (!Session.HasProfile) { <NoProfilePrompt /> return; }
<div class="card">
<SitePicker Profile="Session.CurrentProfile!" @bind-SelectedSites="_sites" />
<div class="form-row mt-8">
<div class="form-group">
<label class="form-label">@T["duplicates.lbl.mode"]</label>
<select class="form-select" @bind="_mode" style="width:120px">
<option value="Files">@T["duplicates.mode.files"]</option>
<option value="Folders">@T["duplicates.mode.folders"]</option>
</select>
</div>
<LibraryPicker Profile="Session.CurrentProfile!" SiteUrl="@(_sites.FirstOrDefault()?.Url)" @bind-Library="_library" Label="@T["duplicates.lbl.library_optional"]" Placeholder="" />
</div>
<div class="flex-row">
<label><input type="checkbox" @bind="_matchSize" /> @T["duplicates.chk.match_size"]</label>
<label><input type="checkbox" @bind="_matchCreated" /> @T["duplicates.chk.match_created"]</label>
<label><input type="checkbox" @bind="_matchModified" /> @T["duplicates.chk.match_modified"]</label>
@if (_mode == "Folders")
{
<label><input type="checkbox" @bind="_matchFolderCount" /> @T["duplicates.chk.match_folder_count"]</label>
<label><input type="checkbox" @bind="_matchFileCount" /> @T["duplicates.chk.match_file_count"]</label>
}
</div>
<div class="flex-row mt-8">
<button class="btn btn-primary" @onclick="RunScan" disabled="@_running">
@(_running ? T["duplicates.btn.scanning"] : T["duplicates.btn.find"])
</button>
@if (_running) { <button class="btn btn-secondary" @onclick="Cancel">@T["btn.cancel"]</button> }
</div>
<ProgressPanel IsRunning="_running" StatusMessage="@_status" Current="_current" Total="_total" />
</div>
@if (!string.IsNullOrEmpty(_error)) { <div class="alert alert-error">@_error</div> }
@if (_results.Count > 0)
{
<div class="card">
<div class="flex-row">
<div class="card-title">@T["duplicates.results.title"] <span class="count-badge">@_results.Count</span></div>
<div class="spacer"></div>
<MergeModeSelect Value="_mergeMode" ValueChanged="v => _mergeMode = v" Visible="_bySite.Count > 1" />
<button class="btn btn-secondary btn-sm" @onclick="ExportCsv">@T["audit.btn.exportCsv"]</button>
<button class="btn btn-secondary btn-sm" @onclick="ExportHtml">@T["audit.btn.exportHtml"]</button>
</div>
@foreach (var g in _results.Take(100))
{
<div style="margin-bottom:8px;border:1px solid var(--border);border-radius:4px;overflow:hidden">
<div style="background:var(--th-bg);padding:6px 12px;font-weight:600;font-size:13px">
@g.Name <span class="chip chip-blue">@g.Items.Count @T["report.text.copies"]</span>
</div>
@foreach (var item in g.Items)
{
<div style="padding:4px 12px;font-size:12px;border-top:1px solid var(--border)">
<span style="color:var(--text-muted)">@item.Library</span> @item.Path
@if (item.SizeBytes.HasValue) { <span class="text-muted"> (@((item.SizeBytes.Value/1024.0).ToString("F1")) KB)</span> }
</div>
}
</div>
}
@if (_results.Count > 100) { <div class="text-muted mt-8">@T["duplicates.results.truncated"]</div> }
</div>
}
@code {
private List<SiteInfo> _sites = new();
private string _library = string.Empty, _mode = "Files";
private bool _matchSize = true, _matchCreated, _matchModified, _matchFolderCount, _matchFileCount;
private bool _running; private string _status = string.Empty, _error = string.Empty;
private int _current, _total;
private List<DuplicateGroup> _results = new();
private List<(string Label, IReadOnlyList<DuplicateGroup> Results)> _bySite = new();
private ReportMergeMode _mergeMode = ReportMergeMode.SingleMerged;
private CancellationTokenSource? _cts;
private async Task RunScan()
{
_error = string.Empty; _results = new(); _bySite = new(); _running = true;
_cts = new CancellationTokenSource();
if (_sites.Count == 0) { _error = "Please select at least one site."; _running = false; return; }
var progress = new Progress<OperationProgress>(p => { _status = p.Message; _current = p.Current; _total = p.Total; InvokeAsync(StateHasChanged); });
try
{
var opts = new DuplicateScanOptions(_mode, _matchSize, _matchCreated, _matchModified, _matchFolderCount, _matchFileCount, Library: _library.TrimOrNull());
var bySite = new List<(string, IReadOnlyList<DuplicateGroup>)>();
var flat = new List<DuplicateGroup>();
int i = 0;
foreach (var site in _sites)
{
_cts.Token.ThrowIfCancellationRequested();
_status = $"Scanning {site.Title} ({++i}/{_sites.Count})…";
await InvokeAsync(StateHasChanged);
var groups = await Elevation.RunAsync(async c =>
{
var ctx = await SessionMgr.GetOrCreateContextAsync(site.Url, Session.CurrentProfile!, c);
return await DupSvc.ScanDuplicatesAsync(ctx, opts, progress, c);
}, _cts.Token);
bySite.Add((site.Title, groups));
flat.AddRange(groups);
}
_bySite = bySite; _results = flat;
_status = $"Found {_results.Count} duplicate groups across {_sites.Count} site(s).";
await Audit.LogAsync("DuplicateScan", Session.CurrentProfile?.Name ?? "", _sites.Select(s => s.Url),
$"{_results.Count} groups; mode={_mode} lib=[{_library}]");
}
catch (OperationCanceledException) { _status = "Cancelled."; }
catch (Exception ex) { _error = ex.Message; }
finally { _running = false; await InvokeAsync(StateHasChanged); }
}
private void Cancel() => _cts?.Cancel();
private async Task ExportCsv()
{
var ts = DateTime.Now.ToString("yyyyMMdd_HHmmss");
var output = ReportMergeHelper.Build(_bySite, _mergeMode, "duplicates", ts, ReportFormat.Csv, rs => CsvExport.BuildCsv(rs));
await WebExport.DownloadBytesAsync(output.Bytes, output.FileName, output.Mime);
}
private async Task ExportHtml()
{
var ts = DateTime.Now.ToString("yyyyMMdd_HHmmss");
var output = ReportMergeHelper.Build(_bySite, _mergeMode, "duplicates", ts, ReportFormat.Html, rs => HtmlExport.BuildHtml(rs, Session.CurrentBranding));
await WebExport.DownloadBytesAsync(output.Bytes, output.FileName, output.Mime);
}
}