Compare commits

...

33 Commits

Author SHA1 Message Date
kawa 9480e9537a Bump dependencies to latest and document them
Update 13 NuGet packages, including major bumps: Microsoft.Graph
5.74.0 -> 6.2.0, Microsoft.Kiota.* 1.22.2 -> 2.0.0, and
Serilog.AspNetCore 9.0.0 -> 10.0.0. The set is internally consistent
(Graph 6.2.0 -> Graph.Core 4.0.1 -> Kiota 2.0.0) and builds clean with
no code changes. Add a Dependencies table to the README listing each
package, its pinned version, and purpose.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 11:44:48 +02:00
kawa 181c82d310 Document certificate (app-only) auth as primary per-profile path
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 11:38:24 +02:00
kawa c4a1775d7d Harden auth, headers, and container per OWASP review
- Add per-account lockout + IP rate limiter on local sign-in (A07)
- Emit CSP and security headers on every response (A05)
- Run container as non-root `app`, /data 0700 (A05/A02)
- Stop reflecting raw token-endpoint body into redirect URL (A09)
- Handle missing refresh_token in connect callback without a 500

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 14:30:19 +02:00
kawa 0adc2d4300 Hide write-only features from TechN0 menu
Read-only TechN0 users could see nav items for pages that immediately
return a WriteGuard notice (transfer, versions, templates, bulk members/
sites, folder structure), landing them on empty screens. Add a `write`
nav scope (HasProfile && Role >= TechN1) so those items no longer appear
for N0. The Bulk and Config section headers drop out automatically since
all their children are now write-scoped. Per-page guards remain intact.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 11:48:36 +02:00
kawa 17f6010a93 Fix open-redirect token leak and related auth hardening
Security review fixes:
- Constrain OAuth connect returnUrl to a site-relative path so the
  redeemable token_key can't be redirected off-domain (was a refresh-
  token leak / connection hijack)
- Route all login redirects (entra/dev/local) through ToLocalReturnUrl,
  also closing a protocol-relative // open redirect in local-login
- Neutralize CSV formula prefixes in both audit-log exporters via
  CsvSanitizer
- Force Secure flag on the prod auth cookie (Always, not SameAsRequest)
- Gate admin pages with an app_role-claim "Admin" policy instead of a
  render-time check

Findings and rationale recorded in SECURITY-TODO.md.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 11:39:20 +02:00
kawa fe33960c0e Let standard techs use profiles without sign-in; flag unshared ones
Standard technicians (TechN0/TechN1) are no longer auto-prompted for a
delegated SharePoint sign-in when selecting a profile — only admins are.
Techs operate under the profile's app (certificate) identity, so a profile
selection never forces them to authenticate.

To keep that usable, the admin profile list now shows a "No shared access"
badge on any profile that isn't certificate-configured, since standard
techs can't operate against those until an admin registers a cert.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 11:27:46 +02:00
kawa 84b77d99f6 Fixed : Certificates arent stored properly app-side when creating new profiles 2026-06-11 11:12:31 +02:00
kawa 08dd27d91d Trim "no secrets stored on disk" from profiles subtitle
Drop the trailing claim from the profiles.subtitle string (FR + EN).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 10:59:49 +02:00
kawa 38ffe714a2 Restore clean role-change success message
Drop the temporary "saved: …" diagnostic wording now that the production
interactivity bug is fixed. Keeps the robust @onchange handler and the
previous-role return value used in the audit entry.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 10:56:47 +02:00
kawa def8647de1 Drop --no-restore: it dropped Blazor framework assets
Root cause of the production "nothing is interactive" bug. The Dockerfile
restored with only the .csproj present (the layer-cache step) and then ran
`dotnet publish --no-restore`. That combination silently omits the Blazor
framework static assets (wwwroot/_framework/blazor.web.js) from the publish
output, so MapStaticAssets 404s the boot script and no interactive circuit
starts on any page — buttons, dropdowns (role changes) all dead.

Letting publish restore against the full project re-materializes the assets.
Reproduced locally and verified the fix. The SDK pin (10.0.203) was a red
herring and is left as-is for reproducibility.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 10:51:25 +02:00
kawa f6a36f3bd9 Use SDK 10.0.203 (10.0.204 has no MCR image)
The previous pin (sdk:10.0.204) doesn't exist on MCR — only the installer
SDK uses that patch. MCR publishes band-2 images up to 10.0.203. Band 2
publishes blazor.web.js correctly (verified locally on 10.0.204), so pin
to the newest available 2xx image, 10.0.203.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 10:37:04 +02:00
kawa c41abc0ea5 Pin build SDK to 10.0.204 to restore blazor.web.js
SDK 10.0.300 published a static-assets endpoints manifest without
blazor.web.js, so MapStaticAssets returned 404 for the Blazor boot script
in production. With no boot script the interactive circuit never starts
and every page renders static — buttons and dropdowns (e.g. user role
changes) do nothing. SDK 10.0.204 is verified to publish blazor.web.js
(physical file + manifest entry) against the 10.0.8 runtime.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 10:34:06 +02:00
kawa fe0fcdb7da Make role-change report saved value on-screen
@bind:after did not persist reliably. Move back to an explicit @onchange
handler and surface every outcome in the page alert, including the role
re-read from the store after the write. This makes a failed save visible
(unrecognized value, exception, or saved != selected) instead of silent,
so we can pinpoint where the role update breaks.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 10:17:27 +02:00
kawa cdc93d041a Fix role change silently failing via @bind
The role <select> used a manual value=/@onchange pattern that parsed
e.Value and returned silently when the parse failed, so changing a role
did nothing and showed no message. Switch to @bind + @bind:after so the
framework handles the enum conversion, and log/verify the persisted role
in UpdateRoleAsync (now returns the previous role) for diagnosis.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 10:10:00 +02:00
kawa 98683bbd5e Re-read user role from store on navigation
Role lived in the scoped UserContextAccessor for the circuit's lifetime
and was never refreshed, so an admin promoting a user (e.g. N0 to N1) did
not reach the affected user's live session. AppInitializer now re-reads
the user on each LocationChanged, applying role changes on next navigation.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 09:50:26 +02:00
kawa e190e40b07 Force request host/scheme to App__Domain behind a proxy
The cookie login redirect and other absolute URLs are built from Request.Host;
behind a proxy that doesn't forward the Host header that's the internal IP:port,
so hitting the domain 302'd to the server IP. Rewrite scheme+host to App__Domain
on every request (after UseForwardedHeaders) so all generated URLs stay on the
public domain.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 15:54:30 +02:00
kawa 5f51e9d16d Pin OIDC redirect to App__Domain when set
Override the OIDC redirect_uri (and post-logout redirect) to <domain>/signin-oidc
instead of deriving it from the request host. Set in both the authorize request
and the code->token redemption so Entra sees a matching redirect_uri. Falls back
to request-host derivation when App__Domain is unset. Domain binding hoisted so
OIDC and ClientConnect share one AppDomainOptions.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 15:47:04 +02:00
kawa 582cc54189 Add App__Domain config to derive connect redirect URI
Let deployments set a single App__Domain (e.g. sptb.example.com) instead of
spelling out the full ClientConnect__RedirectUri. The SharePoint-connect
callback is derived as <domain>/connect/callback; an explicit RedirectUri
still wins for back-compat.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 15:42:05 +02:00
kawa 0ded1af6bc Merge pull request 'Add prebuilt docker-compose, .env.example, and prebuilt install docs' (#4) from fix/prod-auth-http-deploy into main
Reviewed-on: #4
2026-06-10 15:38:44 +02:00
kawa ad7d20021d Add prebuilt docker-compose, .env.example, and prebuilt install docs 2026-06-10 15:33:07 +02:00
kawa 415ec7152f Merge branch 'main' of https://git.azuze.fr/kawa/SharepointToolbox-Web 2026-06-10 14:24:32 +02:00
kawa 5333a3888e Build and push script 2026-06-10 14:24:20 +02:00
kawa 82b7640f31 Merge pull request 'Fix stuck-on-loading after sign-in; enable HTTP/LAN local login' (#3) from fix/prod-auth-http-deploy into main
Reviewed-on: #3
2026-06-10 11:54:10 +02:00
kawa 4c2605b532 Added a docker publish script 2026-06-10 11:51:35 +02:00
kawa e3926804a9 Clarify the two OAuth redirect URIs in README
The Configuration table listed ClientConnect__RedirectUri (/connect/callback)
alongside the Oidc__* settings, implying it was an OIDC sign-in redirect URI on
the toolbox's own Entra app. It isn't: /connect/callback is the per-profile
SharePoint connect flow (PKCE public client using each profile's own ClientId),
registered on the client-tenant apps — not the sign-in app.

Split the two flows out explicitly: /signin-oidc on the sign-in (Web) app,
/connect/callback on each profile's (public client) app. Also document that the
confidential sign-in app needs an HTTPS redirect URI (http only for localhost),
so a plain-HTTP LAN deployment needs an HTTPS-terminating proxy or must fall
back to local login.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 17:51:55 +02:00
kawa 80f660053d Strip quotes/whitespace from Oidc config values
docker-compose's `environment` list form embeds literal quotes in the value
(`- Oidc__TenantId="<guid>"` → the value is "<guid>" with quotes), producing a
malformed Authority URL (…/"<tenant>"/v2.0). Metadata discovery then fails with
IDX20803 and the Microsoft sign-in challenge 500s. The same trap on ClientSecret
would silently break the token exchange.

Trim surrounding quotes and whitespace from TenantId, ClientId and ClientSecret
so a quoted env var no longer breaks OIDC.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 17:32:58 +02:00
kawa 8dfbf7c18a Fix OIDC stuck-on-loading: slim auth cookie principal
The OIDC OnTokenValidated handler stored the raw principal (all id_token +
userinfo claims) in the auth cookie. Encrypted + base64 it exceeds ~4 KB, so
ChunkingCookieManager splits it across …CookiesC1/C2. The chunked cookie
survives the prerender GET but is dropped on the Blazor interactive WebSocket
upgrade, so the circuit comes up anonymous and the page sticks on "Chargement…".
SaveTokens=false alone didn't shrink it enough — the claims themselves bloat it.

Replace the principal with a slim 4-claim identity (preferred_username, name,
app_role, auth_provider), identical to the local-login path, so the cookie
stays single + unchunked and the circuit authenticates.

Also fixes a latent bug: the OIDC principal never carried app_role or
auth_provider, so Entra admins got no admin nav and logout skipped the OIDC
sign-out branch.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 17:21:46 +02:00
kawa c23039efa1 Fix stuck-on-loading after sign-in; enable HTTP/LAN local login
The app stuck on "Chargement…" after sign-in because the interactive
Blazor circuit came up anonymous: no auth cookie reached this origin.
Root cause was the deployment (plain HTTP on an IP, http://host:8080),
which Microsoft OIDC cannot serve — Entra forbids http redirect URIs for
non-localhost hosts, so the sign-in cookie never lands on the origin.

Changes:
- ForwardedHeaders (X-Forwarded-Proto/For) so that behind a TLS proxy the
  app sees the real https scheme, builds a matching OIDC redirect_uri, and
  sets the auth cookie Secure. Proxy IP unknown in-container → known
  proxy/network restrictions cleared.
- First-run bootstrap: seed a local admin (Bootstrap__AdminEmail /
  Bootstrap__AdminPassword) when that email has no account, so HTTP/LAN
  deployments that can't use OIDC can sign in via the local form. Idempotent.
- OIDC SaveTokens=false: the cookie-stored access/id/refresh tokens were
  never read (SharePoint/Graph auth uses the separate connect-flow + cert
  paths). Dropping them keeps the auth cookie small/unchunked.
- AppInitializer now logs which branch leaves UserContext unseeded
  (unauthenticated principal / missing claim / no user row) instead of
  failing silently — this is what surfaced the anonymous-circuit cause.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 15:46:53 +02:00
kawa ebda614aaa Fix prod auth: persist DataProtection keys; redirect unauth to login
Two deployment-breaking issues caused 404s on protected pages after a
container recreate:

1. DataProtection keys were stored in the container's ephemeral home dir.
   Every redeploy regenerated them, invalidating all auth cookies (users
   silently logged out) and — worse — making the app-only certs encrypted
   under /data/appcerts undecryptable. Persist keys to /data/dpkeys with a
   stable application name so they survive recreates.

2. DefaultChallengeScheme was OpenIdConnect, so a logged-out request to any
   [Authorize] Blazor page forced an OIDC challenge. When OIDC is
   unconfigured/unreachable the challenge throws and the request 404s, with
   no path to the login page. Challenge the cookie scheme instead, which
   redirects to /account/login (the combined local + Microsoft page). OIDC
   is still triggered explicitly from /account/login/entra.

Also harden the container image:
- Pin base images to exact patch (sdk:10.0.300, aspnet:10.0.8). Floating
  :10.0 tags drift; a stale/pre-GA SDK base silently drops blazor.web.js
  from the publish manifest, 404ing framework assets in production.
- Install curl and switch the compose healthcheck to it (the aspnet image
  ships no wget/curl, so the old healthcheck always reported unhealthy).
  Probe /account/login (anonymous, 200) since / now 302-redirects.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 14:34:58 +02:00
kawa 3ff0c79950 Add a README 2026-06-09 12:12:08 +02:00
kawa d265a1a81b Fix 404 on _framework assets in production deploy
Blazor framework assets (blazor.web.js) under _framework are served via
the static-asset endpoints manifest, not physical wwwroot files. Plain
UseStaticFiles only serves physical files, so published deployments
returned 404 for blazor.web.js (worked in dev via the dev-time static
web assets provider). Switch to MapStaticAssets, which reads the
endpoints manifest shipped with publish.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 11:35:47 +02:00
kawa b74d6d6e9a Merge scheduled reports + cert auth + user-access audit fixes 2026-06-08 17:56:10 +02:00
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
59 changed files with 4001 additions and 359 deletions
+21
View File
@@ -0,0 +1,21 @@
# Copy to `.env` beside docker-compose.prebuilt.yml and fill in real values.
# IMPORTANT: do NOT wrap values in quotes — the compose `environment:` list form
# embeds the literal quotes, producing a malformed Authority that fails OIDC
# metadata discovery (IDX20803).
# Image tag to run (default: latest)
SPTB_TAG=latest
# Public domain the app is reached at (e.g. sptb.example.com or https://sptb.example.com).
# Scheme defaults to https when omitted. The SharePoint-connect redirect URI is derived
# from this as <domain>/connect/callback — register that on each client profile's app.
# App__Domain=sptb.example.com
# OIDC app sign-in (required in Production). Authority is derived from TenantId.
Oidc__TenantId=00000000-0000-0000-0000-000000000000
Oidc__ClientId=00000000-0000-0000-0000-000000000000
Oidc__ClientSecret=your-client-secret
# Optional: seed the first admin while the user store is empty (local form login).
# Bootstrap__AdminEmail=admin@example.com
# Bootstrap__AdminPassword=change-me
+5
View File
@@ -64,3 +64,8 @@ data/logs/
data/exports/
data/templates/
data/audit.jsonl
data/appcerts/
# Local secrets
.env
!.env.example
+45 -13
View File
@@ -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 @@
{
<div style="font-size:10px;color:var(--text-muted);margin-top:4px">
SP: @_credUsername
<button class="btn btn-secondary btn-sm" style="padding:2px 6px;font-size:10px;margin-left:4px"
@onclick="ReconnectAsync">@T["nav.reconnect"]</button>
@if (!CurrentProfileUsesCert)
{
<button class="btn btn-secondary btn-sm" style="padding:2px 6px;font-size:10px;margin-left:4px"
@onclick="ReconnectAsync">@T["nav.reconnect"]</button>
}
</div>
}
</div>
@@ -142,14 +146,16 @@
new("/permissions", "🔐", "tab.permissions", "", "profile"),
new("/storage", "💾", "tab.storage", "", "profile"),
new("/duplicates", "📋", "tab.duplicates", "", "profile"),
new("/versions", "🗂️", "versions.tab", "", "profile"),
new("/transfer", "📦", "nav.fileTransfer", "", "profile"),
new("/bulk-members", "👥", "tab.bulkMembers", "nav.section.bulk", "profile"),
new("/bulk-sites", "🌐", "tab.bulkSites", "nav.section.bulk", "profile"),
new("/folder-structure", "📁", "tab.folderStructure", "nav.section.bulk", "profile"),
new("/versions", "🗂️", "versions.tab", "", "write"),
new("/transfer", "📦", "nav.fileTransfer", "", "write"),
new("/bulk-members", "👥", "tab.bulkMembers", "nav.section.bulk", "write"),
new("/bulk-sites", "🌐", "tab.bulkSites", "nav.section.bulk", "write"),
new("/folder-structure", "📁", "tab.folderStructure", "nav.section.bulk", "write"),
new("/user-audit", "👤", "tab.userAccessAudit", "nav.section.audit", "profile"),
new("/user-directory", "📖", "nav.userDirectory", "nav.section.audit", "profile"),
new("/templates", "📐", "tab.templates", "nav.section.config", "profile"),
new("/reports", "📑", "nav.reports", "nav.section.audit", "profile"),
new("/templates", "📐", "tab.templates", "nav.section.config", "write"),
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"),
@@ -164,6 +170,7 @@
.Where(i => i.Scope switch
{
"profile" => Session.HasProfile,
"write" => Session.HasProfile && UserContext.Role >= UserRole.TechN1,
"admin" => UserContext.Role == UserRole.Admin,
"auth" => UserContext.IsAuthenticated,
_ => true
@@ -219,11 +226,25 @@
}
}
// If profile selected but no credentials → show modal
if (Session.HasProfile && !_hasCredentials && _credModal is not null)
await _credModal.ShowAsync();
// If profile selected but no credentials → show modal (cert profiles never prompt)
if (ShouldPromptForCredentials)
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);
// Whether to auto-show the delegated sign-in modal. Only admins are ever asked to
// authenticate: standard technicians (TechN0/TechN1) operate under the profile's app
// (certificate) identity and must never be prompted when selecting a profile. A profile
// that isn't cert-configured is an admin setup concern, not a sign-in for the technician.
private bool ShouldPromptForCredentials =>
Session.HasProfile && !_hasCredentials && !CurrentProfileUsesCert
&& UserContext.Role == UserRole.Admin
&& _credModal is not null;
private async Task HandleOAuthCallbackAsync()
{
var uri = new Uri(Nav.Uri);
@@ -256,6 +277,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).
@@ -299,8 +330,9 @@
// operating on the old connection.
await RefreshCredentialState();
// New profile selected and no valid credentials for it → prompt to connect.
if (Session.HasProfile && !_hasCredentials && _credModal is not null)
await _credModal.ShowAsync();
// Standard technicians are never prompted (see ShouldPromptForCredentials).
if (ShouldPromptForCredentials)
await _credModal!.ShowAsync();
});
}
+1 -1
View File
@@ -1,5 +1,5 @@
@page "/admin/audit"
@attribute [Microsoft.AspNetCore.Authorization.Authorize]
@attribute [Microsoft.AspNetCore.Authorization.Authorize(Policy = "Admin")]
@inject IAuditService AuditService
@inject IUserContextAccessor UserContext
@inject NavigationManager Nav
+4 -5
View File
@@ -1,5 +1,5 @@
@page "/admin/users"
@attribute [Microsoft.AspNetCore.Authorization.Authorize]
@attribute [Microsoft.AspNetCore.Authorization.Authorize(Policy = "Admin")]
@inject IUserService UserService
@inject IUserContextAccessor UserContext
@inject IAuditService Audit
@@ -87,7 +87,7 @@ else
<td style="padding:8px">
<select class="form-input" style="width:130px"
value="@user.Role"
@onchange="e => OnRoleChange(user, e)"
@onchange="@(e => OnRoleChange(user, e))"
disabled="@(user.Email == UserContext.Email)">
@foreach (var role in Enum.GetValues<UserRole>())
{
@@ -198,9 +198,8 @@ else
if (!Enum.TryParse<UserRole>(e.Value?.ToString(), out var newRole)) return;
try
{
var oldRole = user.Role;
await UserService.UpdateRoleAsync(user.Id, newRole);
user.Role = newRole;
var oldRole = await UserService.UpdateRoleAsync(user.Id, newRole);
user.Role = newRole;
await Audit.LogAsync("RoleChanged", "", Array.Empty<string>(),
$"Changed role for {user.Email} ({user.DisplayName}) from {oldRole} to {newRole}.");
_message = string.Format(T["usermgmt.msg.roleupdated"], user.DisplayName);
+1 -1
View File
@@ -61,7 +61,7 @@
@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:#f0f0f0;padding:6px 12px;font-weight:600;font-size:13px">
<div style="background:var(--th-bg);padding:6px 12px;font-weight:600;font-size:13px">
@g.Name <span class="chip chip-blue">@g.Items.Count @T["report.text.copies"]</span>
</div>
@foreach (var item in g.Items)
+189 -9
View File
@@ -3,13 +3,17 @@
@inject IUserSessionService Session
@inject IUserContextAccessor UserContext
@inject SharepointToolbox.Web.Infrastructure.Persistence.ProfileRepository ProfileRepo
@inject SharepointToolbox.Web.Infrastructure.Auth.IAppOnlyCertStore CertStore
@inject SharepointToolbox.Web.Infrastructure.Auth.IAppOnlyContextFactory AppOnlyFactory
@inject ISessionCredentialStore CredStore
@inject NavigationManager Nav
@inject SharepointToolbox.Web.Services.OAuth.IEntraDeviceCodeFlow DeviceFlow
@inject SharepointToolbox.Web.Services.Auth.IAppRegistrationService AppRegService
@inject SharepointToolbox.Web.Services.Auth.ICertProvisioningService CertProvisioning
@inject Microsoft.Extensions.Options.IOptions<SharepointToolbox.Web.Core.Config.ClientConnectOptions> ConnectOpts
@inject TranslationSource T
@rendermode InteractiveServer
@using Microsoft.AspNetCore.Components.Forms
@using Microsoft.AspNetCore.WebUtilities
@using SharepointToolbox.Web.Core.Models
@using SharepointToolbox.Web.Services.Session
@@ -24,7 +28,7 @@
@foreach (var p in _profiles)
{
<div class="card" style="@(Session.CurrentProfile?.Id == p.Id ? "border-color:#0078d4;border-width:2px" : "")">
<div class="card" style="@(Session.CurrentProfile?.Id == p.Id ? "border-color:var(--accent);border-width:2px" : "")">
<div class="flex-row">
<div>
<div style="font-weight:600;font-size:15px">@p.Name</div>
@@ -62,7 +66,7 @@
@foreach (var p in _profiles)
{
<div class="card" style="@(Session.CurrentProfile?.Id == p.Id ? "border-color:#0078d4;border-width:2px" : "")">
<div class="card" style="@(Session.CurrentProfile?.Id == p.Id ? "border-color:var(--accent);border-width:2px" : "")">
<div class="flex-row">
<div>
<div style="font-weight:600;font-size:15px">@p.Name</div>
@@ -75,6 +79,13 @@
{
<span class="chip chip-green">@T["profiles.active"]</span>
}
@if (!AppOnlyFactory.IsConfigured(p))
{
<span class="chip chip-yellow"
title="No certificate configured — standard technicians can't use this profile. Open it and run 'Register app' (or upload a certificate) to enable shared access.">
⚠ No shared access
</span>
}
<button class="btn btn-secondary btn-sm" @onclick="() => SelectProfile(p)">
@(Session.CurrentProfile?.Id == p.Id ? T["profiles.selected"] : T["profiles.select"])
</button>
@@ -86,7 +97,7 @@
@if (_showForm)
{
<div class="card" style="border-color:#0078d4">
<div class="card" style="border-color:var(--accent)">
<div class="card-title">@(_editing?.Id == null ? T["profiles.form.new"] : T["profiles.form.edit"])</div>
@if (!string.IsNullOrEmpty(_formError))
{
@@ -151,6 +162,64 @@
<LogoUpload Value="_form.ClientLogo" ValueChanged="(LogoData? l) => _form.ClientLogo = l" />
</div>
@* ── App-only credentials for scheduled (unattended) reports ── *@
<div class="form-group" style="border-top:1px solid var(--border);padding-top:14px;margin-top:10px">
<label class="form-label" style="font-size:14px;font-weight:600">Certificate auth (app identity)</label>
@if (_editing is null)
{
<div class="alert alert-info">Save this client first, then re-open it to configure certificate credentials.</div>
}
else
{
<small class="text-muted d-block" style="margin-bottom:8px">
When enabled, this client uses a certificate-based app registration with
<strong>application</strong> permissions (Sites.FullControl.All, admin-consented) for
<strong>both</strong> interactive work and scheduled reports. Technicians never sign in
to SharePoint per profile. The <em>Register app</em> button provisions the certificate
and consent automatically; the fields below are for manual setup.
</small>
<label style="display:block;margin-bottom:8px">
<input type="checkbox" @bind="_form.AppOnlyEnabled" /> Use certificate auth for this client (no per-profile sign-in)
</label>
<label class="form-label">App-only client (application) ID</label>
<input class="form-input" @bind="_form.AppOnlyClientId"
placeholder="GUID of the app registration used for app-only auth" />
<label class="form-label mt-8">Certificate (.pfx)</label>
@if (_certPresent)
{
<div class="flex-row" style="gap:8px;align-items:center">
<span class="chip chip-green">Certificate stored</span>
@if (!string.IsNullOrEmpty(_form.AppOnlyCertThumbprint))
{
<span class="text-muted" style="font-size:12px">@_form.AppOnlyCertThumbprint</span>
}
<button class="btn btn-secondary btn-sm" @onclick="RemoveCertAsync">Remove</button>
</div>
}
else
{
<div class="flex-row" style="gap:8px;align-items:center;flex-wrap:wrap">
<InputFile OnChange="OnCertSelected" accept=".pfx,.p12" />
<input class="form-input" type="password" @bind="_certPassword"
placeholder="PFX password (if any)" style="max-width:220px" />
<button class="btn btn-secondary btn-sm" @onclick="UploadCertAsync" disabled="@(_pfxBytes is null)">
Upload certificate
</button>
</div>
}
<div class="flex-row mt-8" style="gap:8px;align-items:center">
<button class="btn btn-secondary btn-sm" @onclick="TestAppOnlyAsync" disabled="@_appOnlyTesting">
@(_appOnlyTesting ? "Testing…" : "Test connection")
</button>
@if (!string.IsNullOrEmpty(_appOnlyStatus)) { <span class="text-muted" style="font-size:12px">@_appOnlyStatus</span> }
</div>
}
</div>
<div class="flex-row mt-8">
<button class="btn btn-primary" @onclick="SaveProfile">@T["profile.save"]</button>
<button class="btn btn-secondary" @onclick="CancelForm">@T["btn.cancel"]</button>
@@ -171,10 +240,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 +290,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 +337,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)
{
@@ -301,7 +400,10 @@
if (_editing == null)
{
_form.Id = Guid.NewGuid().ToString();
// Keep the Id assigned by the TenantProfile constructor. "Register app" may have
// already provisioned the cert and stored it on disk under this Id; reassigning a
// fresh Guid here would orphan that cert file and make the UI re-prompt for upload.
if (string.IsNullOrEmpty(_form.Id)) _form.Id = Guid.NewGuid().ToString();
_profiles.Add(_form);
}
else
@@ -317,6 +419,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);
}
}
+101
View File
@@ -0,0 +1,101 @@
@page "/reports"
@attribute [Microsoft.AspNetCore.Authorization.Authorize]
@inject IUserSessionService Session
@inject SharepointToolbox.Web.Infrastructure.Persistence.GeneratedReportRepository ReportIndex
@inject Microsoft.Extensions.Options.IOptions<SharepointToolbox.Web.Core.Models.AppConfiguration> Cfg
@inject TranslationSource T
@rendermode InteractiveServer
@using SharepointToolbox.Web.Core.Models
@using SharepointToolbox.Web.Services.Session
<h1 class="page-title">Reports</h1>
<p class="page-subtitle">Generated reports for the selected client.</p>
@if (!Session.HasProfile) { <NoProfilePrompt /> return; }
<div class="card">
<div class="flex-row">
<div class="card-title">@Session.CurrentProfile!.Name <span class="count-badge">@_reports.Count</span></div>
<div class="spacer"></div>
<button class="btn btn-secondary btn-sm" @onclick="Reload">Refresh</button>
</div>
@if (_reports.Count == 0)
{
<div class="alert alert-info">No reports generated yet for this client. Schedules run automatically; an admin can create them under Scheduled Reports.</div>
}
else
{
<div class="data-table-wrap">
<table class="data-table">
<thead>
<tr>
<th>Name</th><th>Type</th><th>Generated (UTC)</th><th>Size</th><th>Status</th><th></th>
</tr>
</thead>
<tbody>
@foreach (var r in _reports)
{
<tr>
<td>@(string.IsNullOrEmpty(r.Name) ? "—" : r.Name)</td>
<td>@r.Type</td>
<td>@r.GeneratedUtc.ToString("yyyy-MM-dd HH:mm")</td>
<td>@(r.Status == ReportRunStatus.Success ? $"{r.SizeBytes / 1024.0:F1} KB" : "—")</td>
<td>
@if (r.Status == ReportRunStatus.Success)
{
<span class="chip chip-green">Success</span>
@if (r.Emailed)
{
<span class="chip chip-blue" style="margin-left:4px">Emailed</span>
}
else if (!string.IsNullOrEmpty(r.EmailError))
{
<span class="chip chip-red" style="margin-left:4px" title="@r.EmailError">Email failed</span>
}
}
else
{
<span class="chip chip-red" title="@r.Error">Failed</span>
}
</td>
<td>
<div class="flex-row" style="gap:6px;justify-content:flex-end">
@if (r.Status == ReportRunStatus.Success)
{
<a class="btn btn-secondary btn-sm" href="/reports/download/@r.Id" target="_blank" rel="noopener">Download</a>
}
<button class="btn btn-danger btn-sm" @onclick="() => DeleteAsync(r)">Delete</button>
</div>
</td>
</tr>
}
</tbody>
</table>
</div>
}
</div>
@code {
private List<GeneratedReport> _reports = new();
protected override async Task OnInitializedAsync() => await Reload();
private async Task Reload()
{
if (!Session.HasProfile) { _reports = new(); return; }
_reports = (await ReportIndex.LoadForProfileAsync(Session.CurrentProfile!.Id)).ToList();
}
private async Task DeleteAsync(GeneratedReport r)
{
// Remove the file (best-effort) then the index entry.
if (r.Status == ReportRunStatus.Success && !string.IsNullOrEmpty(r.FileName))
{
var path = System.IO.Path.Combine(Cfg.Value.ExportsFolder, r.ProfileId, System.IO.Path.GetFileName(r.FileName));
try { if (System.IO.File.Exists(path)) System.IO.File.Delete(path); } catch { /* ignore */ }
}
await ReportIndex.DeleteAsync(r.Id);
await Reload();
}
}
+499
View File
@@ -0,0 +1,499 @@
@page "/scheduled-reports"
@attribute [Microsoft.AspNetCore.Authorization.Authorize]
@inject IUserContextAccessor UserContext
@inject SharepointToolbox.Web.Infrastructure.Persistence.ScheduledReportRepository ScheduleRepo
@inject SharepointToolbox.Web.Infrastructure.Persistence.ProfileRepository ProfileRepo
@inject SharepointToolbox.Web.Services.Reports.IScheduledReportRunner Runner
@inject SharepointToolbox.Web.Services.Reports.ScheduledRunCoordinator Coordinator
@inject TranslationSource T
@rendermode InteractiveServer
@using SharepointToolbox.Web.Core.Models
@using SharepointToolbox.Web.Services.Export
<h1 class="page-title">Scheduled Reports</h1>
<p class="page-subtitle">Automatic report generation per client. Generated files appear under Reports and are downloadable there.</p>
@if (UserContext.Role != UserRole.Admin)
{
<div class="alert alert-info">Only administrators can manage scheduled reports.</div>
return;
}
@if (!string.IsNullOrEmpty(_pageMsg)) { <div class="alert alert-info" style="margin-bottom:12px">@_pageMsg</div> }
@if (_appOnlyProfiles.Count == 0)
{
<div class="alert alert-error">
No client has app-only access enabled. Open a client under <a href="/profiles">Client Profiles</a>,
enable scheduled reports, and upload its certificate first.
</div>
}
<div class="flex-row" style="margin-bottom:16px">
<button class="btn btn-primary" @onclick="AddNew" disabled="@(_appOnlyProfiles.Count == 0)">New schedule</button>
<div class="spacer"></div>
@if (Coordinator.IsPaused)
{
<span class="chip chip-blue" style="align-self:center">Scheduler paused</span>
<button class="btn btn-secondary btn-sm" @onclick="ResumeScheduler">Resume scheduler</button>
}
else
{
<button class="btn btn-secondary btn-sm" @onclick="PauseScheduler">Pause scheduler</button>
}
<button class="btn btn-danger btn-sm" @onclick="StopAll">Stop all running</button>
</div>
@if (_schedules.Count == 0 && !_showForm)
{
<div class="alert alert-info">No schedules defined.</div>
}
@foreach (var s in _schedules)
{
<div class="card">
<div class="flex-row">
<div>
<div style="font-weight:600;font-size:15px">
@(string.IsNullOrEmpty(s.Name) ? "(unnamed)" : s.Name)
@if (!s.Enabled) { <span class="chip chip-blue" style="margin-left:6px">Disabled</span> }
</div>
<div class="text-muted">@ClientName(s.ProfileId) · @s.Type · @s.Format · @RecurrenceSummary(s.Recurrence)</div>
<div class="text-muted">
@(s.AllSites ? "All sites" : $"{s.SiteUrls.Count} site(s)") ·
Next: @(s.NextRunUtc?.ToString("yyyy-MM-dd HH:mm 'UTC'") ?? "—") ·
Last: @(s.LastRunUtc?.ToString("yyyy-MM-dd HH:mm 'UTC'") ?? "never")
</div>
</div>
<div class="spacer"></div>
<button class="btn btn-secondary btn-sm" @onclick="() => RunNowAsync(s)" disabled="@Coordinator.IsRunning(s.Id)">
@(Coordinator.IsRunning(s.Id) ? "Running…" : "Run now")
</button>
@if (Coordinator.IsRunning(s.Id))
{
<button class="btn btn-danger btn-sm" @onclick="() => Stop(s)">Stop</button>
}
<button class="btn btn-secondary btn-sm" @onclick="() => ToggleEnabledAsync(s)">@(s.Enabled ? "Disable" : "Enable")</button>
<button class="btn btn-secondary btn-sm" @onclick="() => Edit(s)">Edit</button>
<button class="btn btn-danger btn-sm" @onclick="() => DeleteAsync(s)">Delete</button>
</div>
</div>
}
@if (_showForm)
{
<div class="card" style="border-color:var(--accent)">
<div class="card-title">@(_editing is null ? "New schedule" : "Edit schedule")</div>
@if (!string.IsNullOrEmpty(_formError)) { <div class="alert alert-error">@_formError</div> }
<div class="form-group">
<label class="form-label">Name</label>
<input class="form-input" @bind="_form.Name" placeholder="e.g. Weekly permissions audit" />
</div>
<div class="form-row">
<div class="form-group" style="flex:1">
<label class="form-label">Client</label>
<select class="form-input" @bind="_form.ProfileId">
@foreach (var p in _appOnlyProfiles)
{
<option value="@p.Id">@p.Name</option>
}
</select>
</div>
<div class="form-group" style="flex:1">
<label class="form-label">Report type</label>
<select class="form-input" @bind="_form.Type">
@foreach (var t in Enum.GetValues<ReportType>())
{
<option value="@t">@t</option>
}
</select>
</div>
</div>
@if (_form.Type == ReportType.VersionCleanup)
{
<div class="alert alert-error">
<strong>Destructive action.</strong> Version Cleanup permanently <strong>deletes</strong> old file
versions across the selected sites every time it runs — unattended, with no confirmation.
The report is only a summary of what was deleted. Output is HTML (no CSV).
</div>
}
<div class="form-row">
<div class="form-group" style="flex:1">
<label class="form-label">Format</label>
<select class="form-input" @bind="_form.Format" disabled="@(_form.Type == ReportType.VersionCleanup)">
@foreach (var f in Enum.GetValues<ReportFormat>())
{
<option value="@f">@f</option>
}
</select>
</div>
<div class="form-group" style="flex:1">
<label class="form-label">Multi-site bundling</label>
<select class="form-input" @bind="_form.MergeMode">
@foreach (var m in Enum.GetValues<ReportMergeMode>())
{
<option value="@m">@m</option>
}
</select>
</div>
</div>
@* ── Site scope ── *@
<div class="form-group">
<label><input type="checkbox" @bind="_form.AllSites" /> All sites in the tenant (auto-discovered)</label>
@if (!_form.AllSites)
{
<label class="form-label mt-8">Site URLs (one per line)</label>
<textarea class="form-textarea" rows="3" @bind="_siteUrlsText"
placeholder="https://contoso.sharepoint.com/sites/Marketing"></textarea>
}
</div>
@* ── Recurrence ── *@
<div class="form-row">
<div class="form-group" style="flex:1">
<label class="form-label">Frequency</label>
<select class="form-input" @bind="_form.Recurrence.Frequency">
@foreach (var f in Enum.GetValues<ReportFrequency>())
{
<option value="@f">@f</option>
}
</select>
</div>
<div class="form-group" style="flex:1">
<label class="form-label">Time (UTC, HH:mm)</label>
<input class="form-input" @bind="_form.Recurrence.TimeOfDayUtc" placeholder="06:00" />
</div>
@if (_form.Recurrence.Frequency == ReportFrequency.Weekly)
{
<div class="form-group" style="flex:1">
<label class="form-label">Day of week</label>
<select class="form-input" @bind="_form.Recurrence.DayOfWeek">
@foreach (var d in Enum.GetValues<DayOfWeek>())
{
<option value="@d">@d</option>
}
</select>
</div>
}
else if (_form.Recurrence.Frequency == ReportFrequency.Monthly)
{
<div class="form-group" style="flex:1">
<label class="form-label">Day of month</label>
<input class="form-input" type="number" min="1" max="31" @bind="_form.Recurrence.DayOfMonth" />
</div>
}
</div>
@* ── Type-specific options ── *@
<div class="form-group" style="border-top:1px solid var(--border);padding-top:12px">
<label class="form-label" style="font-weight:600">Options</label>
@switch (_form.Type)
{
case ReportType.Permissions:
<div style="display:flex;gap:16px;flex-wrap:wrap;align-items:center">
<label><input type="checkbox" @bind="_form.Options.IncludeInherited" /> Include inherited</label>
<label><input type="checkbox" @bind="_form.Options.ScanFolders" /> Scan folders</label>
<label><input type="checkbox" @bind="_form.Options.IncludeSubsites" /> Include subsites</label>
<label>Folder depth <input class="form-input" type="number" min="0" max="999" style="width:80px" @bind="_form.Options.FolderDepth" /></label>
</div>
break;
case ReportType.Storage:
<div style="display:flex;gap:16px;flex-wrap:wrap;align-items:center">
<label><input type="checkbox" @bind="_form.Options.IncludeSubsites" /> Include subsites</label>
<label><input type="checkbox" @bind="_form.Options.IncludeHiddenLibraries" /> Include hidden libraries</label>
<label><input type="checkbox" @bind="_form.Options.IncludeRecycleBin" /> Include recycle bin</label>
<label>Folder depth <input class="form-input" type="number" min="0" max="20" style="width:80px" @bind="_form.Options.FolderDepth" /></label>
</div>
break;
case ReportType.Duplicates:
<div style="display:flex;gap:16px;flex-wrap:wrap;align-items:center">
<label>Mode
<select class="form-input" style="width:120px" @bind="_form.Options.DuplicateMode">
<option value="Files">Files</option>
<option value="Folders">Folders</option>
</select>
</label>
<label><input type="checkbox" @bind="_form.Options.MatchSize" /> Match size</label>
<label><input type="checkbox" @bind="_form.Options.IncludeSubsites" /> Include subsites</label>
<label>Library <input class="form-input" style="width:160px" @bind="_form.Options.Library" placeholder="(all)" /></label>
</div>
break;
case ReportType.Search:
<div style="display:flex;gap:16px;flex-wrap:wrap;align-items:center">
<label>Extensions <input class="form-input" style="width:180px" @bind="_extensionsText" placeholder="pdf, docx" /></label>
<label>Regex <input class="form-input" style="width:180px" @bind="_form.Options.Regex" placeholder="(optional)" /></label>
<label>Max results <input class="form-input" type="number" min="1" style="width:100px" @bind="_form.Options.MaxResults" /></label>
<label>Library <input class="form-input" style="width:160px" @bind="_form.Options.Library" placeholder="(all)" /></label>
</div>
break;
case ReportType.UserAccess:
<label class="form-label">Target users (login/email, one per line)</label>
<textarea class="form-textarea" rows="2" @bind="_targetUsersText" placeholder="alice@contoso.com&#10;bob@contoso.com"></textarea>
<div style="display:flex;gap:16px;flex-wrap:wrap;align-items:center;margin-top:8px">
<label><input type="checkbox" @bind="_form.Options.IncludeInherited" /> Include inherited</label>
<label><input type="checkbox" @bind="_form.Options.IncludeSubsites" /> Include subsites</label>
</div>
break;
case ReportType.VersionCleanup:
<div style="display:flex;gap:16px;flex-wrap:wrap;align-items:center">
<label>Libraries (comma separated, blank = all) <input class="form-input" style="width:220px" @bind="_libraryTitlesText" /></label>
<label>Keep last <input class="form-input" type="number" min="0" style="width:80px" @bind="_form.Options.KeepLast" /></label>
<label><input type="checkbox" @bind="_form.Options.KeepFirst" /> Keep first version</label>
</div>
break;
}
</div>
@* ── Email delivery ── *@
<div class="form-group" style="border-top:1px solid var(--border);padding-top:12px">
<label style="font-weight:600"><input type="checkbox" @bind="_form.Email.Enabled" /> Email the report via Graph</label>
@if (_form.Email.Enabled)
{
<div class="alert alert-info" style="margin-top:8px">
Sent through the client's app-only certificate (requires the <strong>Mail.Send</strong> application
permission — re-run onboarding if the app was registered before this was added). The report file is attached.
</div>
<div class="form-group">
<label class="form-label">From (sender mailbox)</label>
<input class="form-input" @bind="_form.Email.From" placeholder="reports@contoso.com" />
</div>
<div class="form-row">
<div class="form-group" style="flex:1">
<label class="form-label">To (one per line)</label>
<textarea class="form-textarea" rows="2" @bind="_emailToText" placeholder="alice@contoso.com"></textarea>
</div>
<div class="form-group" style="flex:1">
<label class="form-label">Cc (one per line)</label>
<textarea class="form-textarea" rows="2" @bind="_emailCcText" placeholder="(optional)"></textarea>
</div>
</div>
<div class="form-group">
<label class="form-label">Subject</label>
<input class="form-input" @bind="_form.Email.Subject" />
</div>
<div class="form-group">
<label class="form-label">Body (HTML)</label>
<textarea class="form-textarea" rows="5" @bind="_form.Email.Body"></textarea>
<div class="text-muted" style="margin-top:4px">
Placeholders: {ReportName} {ClientName} {ReportType} {FileName} {DateUtc}
</div>
</div>
}
</div>
<div class="form-group">
<label><input type="checkbox" @bind="_form.Enabled" /> Enabled</label>
</div>
<div class="flex-row mt-8">
<button class="btn btn-primary" @onclick="SaveAsync">Save</button>
<button class="btn btn-secondary" @onclick="() => _showForm = false">Cancel</button>
</div>
</div>
}
@code {
private List<ScheduledReport> _schedules = new();
private List<TenantProfile> _appOnlyProfiles = new();
private bool _showForm;
private ScheduledReport? _editing;
private ScheduledReport _form = new();
private string _formError = string.Empty;
private string _pageMsg = string.Empty;
// Textarea/CSV scratch buffers mapped to/from the option lists on save.
private string _siteUrlsText = string.Empty;
private string _extensionsText = string.Empty;
private string _targetUsersText = string.Empty;
private string _libraryTitlesText = string.Empty;
private string _emailToText = string.Empty;
private string _emailCcText = string.Empty;
protected override async Task OnInitializedAsync()
{
if (UserContext.Role != UserRole.Admin) return;
await Reload();
}
private async Task Reload()
{
_schedules = (await ScheduleRepo.LoadAsync()).OrderBy(s => s.Name).ToList();
_appOnlyProfiles = (await ProfileRepo.LoadAsync()).Where(p => p.AppOnlyEnabled).OrderBy(p => p.Name).ToList();
}
private string ClientName(string profileId) =>
_appOnlyProfiles.FirstOrDefault(p => p.Id == profileId)?.Name ?? "(client removed)";
private static string RecurrenceSummary(RecurrenceRule r) => r.Frequency switch
{
ReportFrequency.Daily => $"Daily at {r.TimeOfDayUtc} UTC",
ReportFrequency.Weekly => $"Weekly {r.DayOfWeek} at {r.TimeOfDayUtc} UTC",
ReportFrequency.Monthly => $"Monthly day {r.DayOfMonth} at {r.TimeOfDayUtc} UTC",
_ => r.TimeOfDayUtc
};
private void AddNew()
{
_editing = null;
_form = new ScheduledReport
{
ProfileId = _appOnlyProfiles.FirstOrDefault()?.Id ?? string.Empty,
CreatedBy = UserContext.Email
};
_siteUrlsText = _extensionsText = _targetUsersText = _libraryTitlesText = string.Empty;
_emailToText = _emailCcText = string.Empty;
_formError = string.Empty;
_showForm = true;
}
private void Edit(ScheduledReport s)
{
_editing = s;
// Deep-ish copy so cancel discards edits.
_form = new ScheduledReport
{
Id = s.Id, ProfileId = s.ProfileId, Name = s.Name, Type = s.Type,
AllSites = s.AllSites, SiteUrls = new List<string>(s.SiteUrls),
MergeMode = s.MergeMode, Format = s.Format, Enabled = s.Enabled,
CreatedBy = s.CreatedBy, CreatedUtc = s.CreatedUtc,
LastRunUtc = s.LastRunUtc, NextRunUtc = s.NextRunUtc,
Recurrence = new RecurrenceRule
{
Frequency = s.Recurrence.Frequency, TimeOfDayUtc = s.Recurrence.TimeOfDayUtc,
DayOfWeek = s.Recurrence.DayOfWeek, DayOfMonth = s.Recurrence.DayOfMonth
},
Options = Clone(s.Options),
Email = new ReportEmailSettings
{
Enabled = s.Email.Enabled, From = s.Email.From,
To = new List<string>(s.Email.To), Cc = new List<string>(s.Email.Cc),
Subject = s.Email.Subject, Body = s.Email.Body
}
};
_siteUrlsText = string.Join("\n", s.SiteUrls);
_extensionsText = string.Join(", ", s.Options.Extensions);
_targetUsersText = string.Join("\n", s.Options.TargetUserLogins);
_libraryTitlesText = string.Join(", ", s.Options.LibraryTitles);
_emailToText = string.Join("\n", s.Email.To);
_emailCcText = string.Join("\n", s.Email.Cc);
_formError = string.Empty;
_showForm = true;
}
private static ScheduledReportOptions Clone(ScheduledReportOptions o) => new()
{
IncludeInherited = o.IncludeInherited, ScanFolders = o.ScanFolders, FolderDepth = o.FolderDepth,
IncludeSubsites = o.IncludeSubsites, PerLibrary = o.PerLibrary,
IncludeHiddenLibraries = o.IncludeHiddenLibraries, IncludePreservationHold = o.IncludePreservationHold,
IncludeListAttachments = o.IncludeListAttachments, IncludeRecycleBin = o.IncludeRecycleBin,
DuplicateMode = o.DuplicateMode, MatchSize = o.MatchSize, MatchCreated = o.MatchCreated,
MatchModified = o.MatchModified, MatchSubfolderCount = o.MatchSubfolderCount, MatchFileCount = o.MatchFileCount,
Library = o.Library, LibraryTitles = new List<string>(o.LibraryTitles), KeepLast = o.KeepLast, KeepFirst = o.KeepFirst,
Extensions = new List<string>(o.Extensions), Regex = o.Regex, MaxResults = o.MaxResults,
TargetUserLogins = new List<string>(o.TargetUserLogins)
};
private async Task SaveAsync()
{
_formError = string.Empty;
if (string.IsNullOrWhiteSpace(_form.Name)) { _formError = "Name is required."; return; }
if (string.IsNullOrWhiteSpace(_form.ProfileId)) { _formError = "Select a client."; return; }
// VersionCleanup has no CSV exporter.
if (_form.Type == ReportType.VersionCleanup) _form.Format = ReportFormat.Html;
// Map scratch buffers back to option lists.
_form.SiteUrls = _siteUrlsText.Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries).ToList();
_form.Options.Extensions = _extensionsText.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries).ToList();
_form.Options.TargetUserLogins = _targetUsersText.Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries).ToList();
_form.Options.LibraryTitles = _libraryTitlesText.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries).ToList();
if (!_form.AllSites && _form.SiteUrls.Count == 0) { _formError = "Add at least one site URL, or choose All sites."; return; }
if (_form.Type == ReportType.UserAccess && _form.Options.TargetUserLogins.Count == 0) { _formError = "User Access reports need at least one target user."; return; }
// Map email scratch buffers back to lists and validate when delivery is on.
_form.Email.To = _emailToText.Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries).ToList();
_form.Email.Cc = _emailCcText.Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries).ToList();
if (_form.Email.Enabled)
{
if (string.IsNullOrWhiteSpace(_form.Email.From)) { _formError = "Email delivery needs a sender mailbox (From)."; return; }
if (_form.Email.To.Count == 0 && _form.Email.Cc.Count == 0) { _formError = "Email delivery needs at least one To or Cc recipient."; return; }
}
// Arm the next run from now so the scheduler picks it up on the right cadence.
_form.NextRunUtc = _form.Recurrence.ComputeNextRunUtc(DateTime.UtcNow);
await ScheduleRepo.UpsertAsync(_form);
_showForm = false;
_editing = null;
await Reload();
}
private async Task ToggleEnabledAsync(ScheduledReport s)
{
s.Enabled = !s.Enabled;
if (s.Enabled && s.NextRunUtc is null) s.NextRunUtc = s.Recurrence.ComputeNextRunUtc(DateTime.UtcNow);
await ScheduleRepo.UpsertAsync(s);
await Reload();
}
private async Task DeleteAsync(ScheduledReport s)
{
await ScheduleRepo.DeleteAsync(s.Id);
await Reload();
}
private async Task RunNowAsync(ScheduledReport s)
{
// Register through the coordinator so this manual run is stoppable and can't
// overlap a scheduler-triggered run of the same schedule.
var token = Coordinator.TryBegin(s.Id, CancellationToken.None);
if (token is null) { _pageMsg = $"'{s.Name}' is already running."; return; }
_pageMsg = string.Empty;
await InvokeAsync(StateHasChanged);
try
{
var report = await Runner.RunAsync(s, token.Value);
_pageMsg = report.Status == ReportRunStatus.Success
? $"'{s.Name}' generated {report.FileName}. See Reports."
: $"'{s.Name}' failed: {report.Error}";
}
catch (OperationCanceledException) { _pageMsg = $"'{s.Name}' was stopped."; }
catch (Exception ex) { _pageMsg = $"'{s.Name}' failed: {ex.Message}"; }
finally { Coordinator.Complete(s.Id); await Reload(); }
}
private void Stop(ScheduledReport s) =>
_pageMsg = Coordinator.Cancel(s.Id)
? $"Stop signal sent to '{s.Name}'. It ends after the current site finishes."
: $"'{s.Name}' is not running.";
private void StopAll()
{
var n = Coordinator.CancelAll();
_pageMsg = n == 0 ? "No runs in progress." : $"Stop signal sent to {n} running report(s).";
}
private void PauseScheduler()
{
Coordinator.Pause();
_pageMsg = "Scheduler paused — no schedules will fire until resumed. Runs in progress keep going (Stop them individually).";
}
private void ResumeScheduler()
{
Coordinator.Resume();
_pageMsg = "Scheduler resumed.";
}
}
+118 -21
View File
@@ -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 @@
<textarea class="form-textarea" @bind="_users" placeholder="alice@contoso.com&#10;bob@contoso.com" rows="2"></textarea>
</div>
<SitePicker Profile="Session.CurrentProfile!" @bind-SelectedSites="_sites" />
<div class="text-muted" style="margin-top:4px">@T["audit.hint.allSites"]</div>
<div class="form-row">
<div class="form-group" style="display:flex;align-items:center;gap:16px;padding-top:20px">
<label><input type="checkbox" @bind="_includeInherited" /> @T["audit.chk.includeInherited"]<HelpTip Text="@T["help.inheritedPerms"]" /></label>
@@ -79,29 +81,92 @@
<div class="card">
<div class="flex-row">
<div class="card-title">@T["audit.results.title"] <span class="count-badge">@_results.Count</span></div>
<button class="btn btn-sm @(_viewMode == "site" ? "btn-primary" : "btn-secondary")" @onclick='() => _viewMode = "site"'>@T["audit.view.bySite"]</button>
<button class="btn btn-sm @(_viewMode == "flat" ? "btn-primary" : "btn-secondary")" @onclick='() => _viewMode = "flat"'>@T["audit.view.table"]</button>
<div class="spacer"></div>
<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>
<div class="data-table-wrap">
<table class="data-table">
<thead><tr><th>@T["report.col.user"]</th><th>@T["report.col.site"]</th><th>@T["report.col.object"]</th><th>@T["audit.col.permission"]<HelpTip Text="@T["help.permissionLevel"]" /></th><th>@T["report.col.access_type"]<HelpTip Text="@T["help.accessType"]" Wide="true" /></th><th>@T["report.col.granted_through"]<HelpTip Text="@T["help.grantedThrough"]" /></th></tr></thead>
<tbody>
@foreach (var r in _results.Take(500))
@if (_sitesScanned > 0)
{
<div class="flex-row mt-8" style="gap:8px">
<span class="chip chip-blue">@string.Format(T["audit.scan.sitesScanned"], _sitesScanned)</span>
@if (_sitesDenied > 0) { <span class="chip chip-yellow">@string.Format(T["audit.scan.sitesDenied"], _sitesDenied)</span> }
@if (_sitesFailed > 0) { <span class="chip chip-red">@string.Format(T["audit.scan.sitesFailed"], _sitesFailed)</span> }
</div>
}
@if (_viewMode == "site")
{
<div class="text-muted mt-8" style="margin-bottom:8px">@T["audit.bysite.hint"]</div>
@foreach (var g in _results.GroupBy(r => (r.SiteUrl, r.SiteTitle)).OrderBy(g => g.Key.SiteTitle, StringComparer.OrdinalIgnoreCase))
{
var siteUrl = g.Key.SiteUrl;
var expanded = _expandedSites.Contains(siteUrl);
var hasHigh = g.Any(e => e.IsHighPrivilege);
<div class="site-drill @(expanded ? "open" : "")">
<button class="site-drill-header" @onclick="() => ToggleSite(siteUrl)">
<span class="drill-caret @(expanded ? "open" : "")">▸</span>
<span class="drill-title">@g.Key.SiteTitle</span>
<span class="text-muted drill-url">@g.Key.SiteUrl</span>
<span class="spacer"></span>
@if (hasHigh) { <span class="chip chip-red">@T["audit.chip.high"]</span> }
<span class="count-badge">@g.Count() @T["report.text.permissions_parens"]</span>
</button>
@if (expanded)
{
<tr>
<td>@r.UserDisplayName</td>
<td>@r.SiteTitle</td>
<td>@r.ObjectTitle <span class="text-muted">(@r.ObjectType)</span></td>
<td>@r.PermissionLevel @if (r.IsHighPrivilege) { <span class="chip chip-red">@T["audit.chip.high"]</span> }</td>
<td>@r.AccessType</td>
<td>@r.GrantedThrough</td>
</tr>
<div class="site-drill-body">
<div class="data-table-wrap">
<table class="data-table">
<thead><tr>
@if (_multiUser) { <th>@T["report.col.user"]</th> }
<th>@T["report.col.object"]</th>
<th>@T["audit.col.permission"]<HelpTip Text="@T["help.permissionLevel"]" /></th>
<th>@T["report.col.access_type"]<HelpTip Text="@T["help.accessType"]" Wide="true" /></th>
<th>@T["report.col.granted_through"]<HelpTip Text="@T["help.grantedThrough"]" /></th>
</tr></thead>
<tbody>
@foreach (var r in g)
{
<tr>
@if (_multiUser) { <td>@r.UserDisplayName</td> }
<td>@r.ObjectTitle <span class="text-muted">(@r.ObjectType)</span></td>
<td>@r.PermissionLevel @if (r.IsHighPrivilege) { <span class="chip chip-red">@T["audit.chip.high"]</span> }</td>
<td>@r.AccessType</td>
<td>@r.GrantedThrough</td>
</tr>
}
</tbody>
</table>
</div>
</div>
}
</tbody>
</table>
</div>
@if (_results.Count > 500) { <div class="text-muted mt-8">@T["audit.msg.showFirst500Export"]</div> }
</div>
}
}
else
{
<div class="data-table-wrap">
<table class="data-table">
<thead><tr><th>@T["report.col.user"]</th><th>@T["report.col.site"]</th><th>@T["report.col.object"]</th><th>@T["audit.col.permission"]<HelpTip Text="@T["help.permissionLevel"]" /></th><th>@T["report.col.access_type"]<HelpTip Text="@T["help.accessType"]" Wide="true" /></th><th>@T["report.col.granted_through"]<HelpTip Text="@T["help.grantedThrough"]" /></th></tr></thead>
<tbody>
@foreach (var r in _results.Take(500))
{
<tr>
<td>@r.UserDisplayName</td>
<td>@r.SiteTitle</td>
<td>@r.ObjectTitle <span class="text-muted">(@r.ObjectType)</span></td>
<td>@r.PermissionLevel @if (r.IsHighPrivilege) { <span class="chip chip-red">@T["audit.chip.high"]</span> }</td>
<td>@r.AccessType</td>
<td>@r.GrantedThrough</td>
</tr>
}
</tbody>
</table>
</div>
@if (_results.Count > 500) { <div class="text-muted mt-8">@T["audit.msg.showFirst500Export"]</div> }
}
</div>
}
@@ -147,26 +212,58 @@
private bool _running; private string _status = string.Empty, _error = string.Empty;
private int _current, _total;
private List<UserAccessEntry> _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<string> _expandedSites = new(StringComparer.OrdinalIgnoreCase);
// Show the User column inside the per-site detail only when the audit spans multiple users.
private bool _multiUser => _results.Select(r => r.UserLogin).Distinct(StringComparer.OrdinalIgnoreCase).Count() > 1;
private void ToggleSite(string siteUrl)
{
if (!_expandedSites.Remove(siteUrl)) _expandedSites.Add(siteUrl);
}
private async Task RunAudit()
{
_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<OperationProgress>(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; }
+59 -7
View File
@@ -1,24 +1,76 @@
@inject AuthenticationStateProvider AuthProvider
@inject IUserService UserService
@inject IUserContextAccessor UserContext
@inject NavigationManager Nav
@inject ILogger<AppInitializer> Logger
@implements IDisposable
@using Microsoft.AspNetCore.Components.Authorization
@using Microsoft.AspNetCore.Components.Routing
@using SharepointToolbox.Web.Services.Auth
@using SharepointToolbox.Web.Services.Session
@* Invisible component. Run once per circuit to seed IUserContextAccessor. *@
@* Invisible component. Seeds IUserContextAccessor on circuit init and re-reads it from the
store on every navigation, so an admin's role change applies on the user's next page change. *@
@code {
private string? _email;
protected override async Task OnInitializedAsync()
{
var state = await AuthProvider.GetAuthenticationStateAsync();
var principal = state.User;
if (principal.Identity?.IsAuthenticated != true) return;
var email = principal.FindFirst("preferred_username")?.Value
?? principal.FindFirst(System.Security.Claims.ClaimTypes.Email)?.Value;
if (string.IsNullOrEmpty(email)) return;
if (principal.Identity?.IsAuthenticated != true)
{
Logger.LogWarning("AppInitializer: circuit principal NOT authenticated; UserContext left unseeded → page stays on loading.");
return;
}
var user = await UserService.GetByEmailAsync(email);
if (user is not null) UserContext.Initialize(user);
_email = principal.FindFirst("preferred_username")?.Value
?? principal.FindFirst(System.Security.Claims.ClaimTypes.Email)?.Value;
if (string.IsNullOrEmpty(_email))
{
var claims = string.Join(", ", principal.Claims.Select(c => $"{c.Type}={c.Value}"));
Logger.LogWarning("AppInitializer: authenticated but no preferred_username/email claim. Claims present: [{Claims}]", claims);
return;
}
await SeedAsync(logSeed: true);
// Re-read the user (and current role) from the store on each navigation. The role lives
// in the scoped UserContextAccessor for the circuit's lifetime, so without this a role
// change made by an admin would not reach the affected user's live session.
Nav.LocationChanged += OnLocationChanged;
}
private async Task SeedAsync(bool logSeed)
{
if (string.IsNullOrEmpty(_email)) return;
var user = await UserService.GetByEmailAsync(_email);
if (user is null)
{
Logger.LogWarning("AppInitializer: no user row for email '{Email}' — provisioning did not persist a matching record.", _email);
return;
}
if (logSeed)
Logger.LogInformation("AppInitializer: seeded UserContext for '{Email}' (role {Role}).", user.Email, user.Role);
UserContext.Initialize(user);
}
private async void OnLocationChanged(object? sender, LocationChangedEventArgs e)
{
try
{
await SeedAsync(logSeed: false);
}
catch (Exception ex)
{
Logger.LogWarning(ex, "AppInitializer: failed to refresh UserContext on navigation to '{Location}'.", e.Location);
}
}
public void Dispose() => Nav.LocationChanged -= OnLocationChanged;
}
+36
View File
@@ -0,0 +1,36 @@
namespace SharepointToolbox.Web.Core.Config;
/// <summary>
/// The app's public domain (e.g. <c>sptb.example.com</c> or <c>https://sptb.example.com</c>),
/// configured via <c>App__Domain</c>. Used to derive the SharePoint-connect redirect URI when
/// <see cref="ClientConnectOptions.RedirectUri"/> isn't set explicitly.
/// </summary>
public class AppDomainOptions
{
public string Domain { get; set; } = string.Empty;
/// <summary>
/// Builds an absolute URL for <paramref name="path"/> rooted at the configured domain, or
/// <c>null</c> when no domain is set. Defaults to <c>https</c> when the domain has no scheme,
/// and tolerates accidental surrounding quotes / trailing slashes (docker-compose's list-form
/// env values can embed literal quotes).
/// </summary>
public string? BuildUrl(string path)
{
var domain = Domain?.Trim().Trim('"', '\'').TrimEnd('/');
if (string.IsNullOrEmpty(domain))
return null;
if (!domain.Contains("://", StringComparison.Ordinal))
domain = "https://" + domain;
return domain + "/" + path.TrimStart('/');
}
/// <summary>
/// The configured domain as an absolute base <see cref="Uri"/> (scheme + host [+ port]), or
/// <c>null</c> when no domain is set or it can't be parsed.
/// </summary>
public Uri? GetBaseUri() =>
BuildUrl("/") is { } url && Uri.TryCreate(url, UriKind.Absolute, out var uri) ? uri : null;
}
+14
View File
@@ -4,4 +4,18 @@ public static class StringExtensions
{
public static string? TrimOrNull(this string? s)
=> string.IsNullOrWhiteSpace(s) ? null : s.Trim();
/// <summary>
/// Returns <paramref name="returnUrl"/> only when it is a safe site-relative path,
/// otherwise "/". Rejects absolute URLs and protocol-relative paths ("//evil.com",
/// "/\evil.com") so a post-auth / post-connect redirect can never leave the app.
/// Used by every login and OAuth-connect redirect to prevent open redirects.
/// </summary>
public static string ToLocalReturnUrl(this string? returnUrl)
{
if (string.IsNullOrEmpty(returnUrl)) return "/";
if (returnUrl[0] != '/') return "/"; // not site-relative
if (returnUrl.Length > 1 && (returnUrl[1] == '/' || returnUrl[1] == '\\')) return "/"; // protocol-relative
return returnUrl;
}
}
+92
View File
@@ -0,0 +1,92 @@
using Microsoft.Online.SharePoint.TenantAdministration;
using Microsoft.SharePoint.Client;
using SharepointToolbox.Web.Core.Models;
namespace SharepointToolbox.Web.Core.Helpers;
/// <summary>
/// Enumerates every site collection in a tenant via the SharePoint tenant-admin
/// endpoint (<c>Tenant.GetSitePropertiesFromSharePointByFilters</c>), paging through
/// all results. Shared by the delegated <c>SiteDiscoveryService</c> and the app-only
/// background report scheduler so both produce the identical, complete site set.
///
/// The caller supplies a <see cref="ClientContext"/> already pointed at the tenant
/// admin host (see <see cref="BuildAdminUrl"/>) and authenticated by whichever model
/// applies. The cold-token 403 retry handles the transient denial a freshly minted
/// delegated token hits against the admin host; it is harmless under app-only auth.
/// </summary>
public static class TenantSiteEnumerator
{
private const int MaxColdTokenAttempts = 4;
private const int BackoffBaseSeconds = 3;
public static async Task<IReadOnlyList<SiteInfo>> EnumerateAsync(ClientContext adminCtx, CancellationToken ct)
{
var tenant = new Tenant(adminCtx);
var filter = new SPOSitePropertiesEnumerableFilter
{
IncludeDetail = false,
IncludePersonalSite = PersonalSiteFilter.Exclude,
StartIndex = null,
Template = null,
};
var results = new List<SiteInfo>();
SPOSitePropertiesEnumerable page;
do
{
ct.ThrowIfCancellationRequested();
page = await FetchPageWithColdTokenRetryAsync(adminCtx, tenant, filter, ct);
foreach (var sp in page)
{
var url = sp.Url ?? string.Empty;
if (string.IsNullOrEmpty(url)) continue;
if (url.Contains("/personal/", StringComparison.OrdinalIgnoreCase)) continue;
var title = string.IsNullOrEmpty(sp.Title) ? url : sp.Title;
results.Add(new SiteInfo(url, title));
}
filter.StartIndex = page.NextStartIndexFromSharePoint;
}
while (!string.IsNullOrEmpty(filter.StartIndex));
return results
.GroupBy(s => s.Url, StringComparer.OrdinalIgnoreCase)
.Select(g => g.First())
.OrderBy(s => s.Title, StringComparer.OrdinalIgnoreCase)
.ToList();
}
private static async Task<SPOSitePropertiesEnumerable> FetchPageWithColdTokenRetryAsync(
ClientContext ctx, Tenant tenant, SPOSitePropertiesEnumerableFilter filter, CancellationToken ct)
{
for (int attempt = 1; ; attempt++)
{
try
{
var page = tenant.GetSitePropertiesFromSharePointByFilters(filter);
ctx.Load(page);
await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(ctx, null, ct);
return page;
}
catch (SharePointAccessDeniedException ex) when (attempt < MaxColdTokenAttempts)
{
var delay = TimeSpan.FromSeconds(BackoffBaseSeconds * attempt);
Serilog.Log.Warning(
"Tenant admin endpoint denied during site enumeration (attempt {N}/{Max}); " +
"retrying in {Delay}s. {Err}",
attempt, MaxColdTokenAttempts, delay.TotalSeconds, ex.Message);
await Task.Delay(delay, ct);
}
}
}
/// <summary>https://contoso.sharepoint.com[/sites/Foo] → https://contoso-admin.sharepoint.com</summary>
public static string BuildAdminUrl(string tenantUrl)
{
if (!Uri.TryCreate(tenantUrl, UriKind.Absolute, out var uri))
return tenantUrl;
var adminHost = uri.Host.Replace(".sharepoint.com", "-admin.sharepoint.com",
StringComparison.OrdinalIgnoreCase);
return $"{uri.Scheme}://{adminHost}";
}
}
+3
View File
@@ -4,4 +4,7 @@ public class AppConfiguration
{
public string DataFolder { get; set; } = "/data";
public string ExportsFolder { get; set; } = "/data/exports";
/// <summary>DataProtection-encrypted app-only certificates, one file per client profile.</summary>
public string CertsFolder { get; set; } = "/data/appcerts";
}
+13
View File
@@ -15,4 +15,17 @@ public class AppUser
public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
public DateTimeOffset? LastLogin { get; set; }
// ── Local-account brute-force lockout ───────────────────────────────────────
// Consecutive failed password attempts and, once the threshold is hit, the UTC
// instant the account unlocks again. Only meaningful for AuthProvider.Local.
// A per-account counter (not just an IP rate limiter) is the control that holds
// up here: forwarded headers are trusted from any source, so an attacker who can
// rotate X-Forwarded-For would evade IP-based throttling but not this.
/// <summary>Consecutive failed local-login attempts since the last success.</summary>
public int FailedLoginCount { get; set; }
/// <summary>UTC instant the account unlocks; null when not locked.</summary>
public DateTimeOffset? LockoutEndUtc { get; set; }
}
+51
View File
@@ -0,0 +1,51 @@
namespace SharepointToolbox.Web.Core.Models;
public enum ReportRunStatus
{
Success,
Failed
}
/// <summary>
/// One produced report file, listed per client. The file itself lives under
/// {ExportsFolder}/{ProfileId}/{FileName}; this record is the index entry that the
/// "Reports" list and the id-based download endpoint resolve against.
/// Persisted to reports-index.json.
/// </summary>
public class GeneratedReport
{
public string Id { get; set; } = Guid.NewGuid().ToString();
public string ProfileId { get; set; } = string.Empty;
/// <summary>The schedule that produced this; null for an ad-hoc/manual run.</summary>
public string? ScheduledReportId { get; set; }
public ReportType Type { get; set; }
/// <summary>Human label (usually copied from the schedule name).</summary>
public string Name { get; set; } = string.Empty;
/// <summary>File name on disk, relative to the profile's exports subfolder.</summary>
public string FileName { get; set; } = string.Empty;
public string Mime { get; set; } = string.Empty;
public long SizeBytes { get; set; }
public DateTime GeneratedUtc { get; set; } = DateTime.UtcNow;
public ReportRunStatus Status { get; set; } = ReportRunStatus.Success;
/// <summary>Populated when <see cref="Status"/> is Failed.</summary>
public string? Error { get; set; }
/// <summary>True when the report was successfully emailed via Graph.</summary>
public bool Emailed { get; set; }
/// <summary>
/// Populated when email delivery was requested but failed. The report itself still
/// succeeded (file is on disk) — only delivery failed.
/// </summary>
public string? EmailError { get; set; }
}
+12
View File
@@ -0,0 +1,12 @@
namespace SharepointToolbox.Web.Core.Models;
/// <summary>The kinds of report that can be generated, scheduled, and exported.</summary>
public enum ReportType
{
Permissions,
Storage,
Duplicates,
UserAccess,
VersionCleanup,
Search
}
+196
View File
@@ -0,0 +1,196 @@
using SharepointToolbox.Web.Services.Export;
namespace SharepointToolbox.Web.Core.Models;
/// <summary>How often a scheduled report recurs.</summary>
public enum ReportFrequency
{
Daily,
Weekly,
Monthly
}
/// <summary>
/// A recurrence rule. The report fires at <see cref="TimeOfDayUtc"/> on the cadence
/// described by <see cref="Frequency"/>. <see cref="DayOfWeek"/> applies to Weekly;
/// <see cref="DayOfMonth"/> applies to Monthly (clamped to the last day of short months).
/// All times are UTC to keep scheduling unambiguous across DST.
/// </summary>
public class RecurrenceRule
{
public ReportFrequency Frequency { get; set; } = ReportFrequency.Weekly;
/// <summary>Time of day to run, UTC, "HH:mm".</summary>
public string TimeOfDayUtc { get; set; } = "06:00";
/// <summary>0 = Sunday … 6 = Saturday. Used when <see cref="Frequency"/> is Weekly.</summary>
public DayOfWeek DayOfWeek { get; set; } = DayOfWeek.Monday;
/// <summary>131. Used when <see cref="Frequency"/> is Monthly.</summary>
public int DayOfMonth { get; set; } = 1;
/// <summary>
/// Computes the next fire time strictly after <paramref name="afterUtc"/>.
/// </summary>
public DateTime ComputeNextRunUtc(DateTime afterUtc)
{
var (hh, mm) = ParseTime(TimeOfDayUtc);
switch (Frequency)
{
case ReportFrequency.Daily:
{
var candidate = new DateTime(afterUtc.Year, afterUtc.Month, afterUtc.Day, hh, mm, 0, DateTimeKind.Utc);
if (candidate <= afterUtc) candidate = candidate.AddDays(1);
return candidate;
}
case ReportFrequency.Weekly:
{
var candidate = new DateTime(afterUtc.Year, afterUtc.Month, afterUtc.Day, hh, mm, 0, DateTimeKind.Utc);
int delta = ((int)DayOfWeek - (int)candidate.DayOfWeek + 7) % 7;
candidate = candidate.AddDays(delta);
if (candidate <= afterUtc) candidate = candidate.AddDays(7);
return candidate;
}
case ReportFrequency.Monthly:
default:
{
var candidate = BuildMonthly(afterUtc.Year, afterUtc.Month, hh, mm);
if (candidate <= afterUtc)
{
var next = afterUtc.AddMonths(1);
candidate = BuildMonthly(next.Year, next.Month, hh, mm);
}
return candidate;
}
}
}
private DateTime BuildMonthly(int year, int month, int hh, int mm)
{
int day = Math.Min(DayOfMonth, DateTime.DaysInMonth(year, month));
return new DateTime(year, month, day, hh, mm, 0, DateTimeKind.Utc);
}
private static (int Hour, int Minute) ParseTime(string s)
{
var parts = (s ?? "06:00").Split(':');
int hh = parts.Length > 0 && int.TryParse(parts[0], out var h) ? Math.Clamp(h, 0, 23) : 6;
int mm = parts.Length > 1 && int.TryParse(parts[1], out var m) ? Math.Clamp(m, 0, 59) : 0;
return (hh, mm);
}
}
/// <summary>
/// Flat, serializable bag of the report-generation options used across all report
/// types. Only the fields relevant to the chosen <see cref="ScheduledReport.Type"/>
/// are honoured; the runner maps them to the concrete option records
/// (<see cref="ScanOptions"/>, <see cref="StorageScanOptions"/>, …).
/// </summary>
public class ScheduledReportOptions
{
// Permissions
public bool IncludeInherited { get; set; }
public bool ScanFolders { get; set; } = true;
// Permissions + Storage
public int FolderDepth { get; set; } = 1;
public bool IncludeSubsites { get; set; }
// Storage
public bool PerLibrary { get; set; } = true;
public bool IncludeHiddenLibraries { get; set; } = true;
public bool IncludePreservationHold { get; set; } = true;
public bool IncludeListAttachments { get; set; } = true;
public bool IncludeRecycleBin { get; set; } = true;
// Duplicates
public string DuplicateMode { get; set; } = "Files";
public bool MatchSize { get; set; } = true;
public bool MatchCreated { get; set; }
public bool MatchModified { get; set; }
public bool MatchSubfolderCount { get; set; }
public bool MatchFileCount { get; set; }
public string? Library { get; set; }
// Version cleanup
public List<string> LibraryTitles { get; set; } = new();
public int KeepLast { get; set; } = 5;
public bool KeepFirst { get; set; }
// Search
public List<string> Extensions { get; set; } = new();
public string? Regex { get; set; }
public int MaxResults { get; set; } = 1000;
// User access audit — the logins/emails to report access for (substring matched).
public List<string> TargetUserLogins { get; set; } = new();
}
/// <summary>
/// Optional email delivery for a generated report, sent through Graph (app-only,
/// <c>Mail.Send</c>). The report file is attached. Body/subject support the
/// placeholders {ReportName}, {ClientName}, {ReportType}, {DateUtc} and {FileName}.
/// </summary>
public class ReportEmailSettings
{
public bool Enabled { get; set; }
/// <summary>
/// Mailbox to send AS (UPN or address). App-only Graph has no signed-in user, so a
/// concrete sender mailbox is required — Graph posts to /users/{From}/sendMail.
/// </summary>
public string From { get; set; } = string.Empty;
public List<string> To { get; set; } = new();
public List<string> Cc { get; set; } = new();
public string Subject { get; set; } = "{ClientName} — {ReportName}";
/// <summary>HTML body. Placeholders are substituted before sending.</summary>
public string Body { get; set; } =
"<p>Hello,</p><p>Please find attached the {ReportType} report \"{ReportName}\" for {ClientName}, generated on {DateUtc} UTC.</p>";
}
/// <summary>
/// A user-defined schedule that generates a report for a single client (profile)
/// on a recurrence. Persisted to schedules.json.
/// </summary>
public class ScheduledReport
{
public string Id { get; set; } = Guid.NewGuid().ToString();
/// <summary><see cref="TenantProfile.Id"/> this schedule belongs to.</summary>
public string ProfileId { get; set; } = string.Empty;
/// <summary>Human label shown in the UI.</summary>
public string Name { get; set; } = string.Empty;
public ReportType Type { get; set; }
public ScheduledReportOptions Options { get; set; } = new();
/// <summary>When true, run against every site in the tenant (site discovery); otherwise use <see cref="SiteUrls"/>.</summary>
public bool AllSites { get; set; } = true;
public List<string> SiteUrls { get; set; } = new();
public ReportMergeMode MergeMode { get; set; } = ReportMergeMode.SingleMerged;
public ReportFormat Format { get; set; } = ReportFormat.Html;
public RecurrenceRule Recurrence { get; set; } = new();
/// <summary>Optional Graph email delivery of the generated report.</summary>
public ReportEmailSettings Email { get; set; } = new();
public bool Enabled { get; set; } = true;
public string CreatedBy { get; set; } = string.Empty;
public DateTime CreatedUtc { get; set; } = DateTime.UtcNow;
public DateTime? LastRunUtc { get; set; }
public DateTime? NextRunUtc { get; set; }
}
+37
View File
@@ -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.
/// <summary>When true, this client uses certificate (app-only) auth for interactive and scheduled work.</summary>
public bool AppOnlyEnabled { get; set; }
/// <summary>Client (application) ID of the app-registration used for certificate auth. May differ from <see cref="ClientId"/>.</summary>
public string AppOnlyClientId { get; set; } = string.Empty;
/// <summary>Thumbprint of the stored certificate — display/verification only; the key material is stored separately.</summary>
public string AppOnlyCertThumbprint { get; set; } = string.Empty;
/// <summary>
/// Clones this profile pointed at a different site/admin URL, preserving every other
/// field (notably the certificate metadata) so the auth model is resolved identically
/// for the derived URL. Use instead of hand-building partial copies.
/// </summary>
public TenantProfile CloneForSite(string siteUrl) => new()
{
Id = Id,
Name = Name,
TenantUrl = siteUrl,
TenantId = TenantId,
ClientId = ClientId,
ClientLogo = ClientLogo,
AppOnlyEnabled = AppOnlyEnabled,
AppOnlyClientId = AppOnlyClientId,
AppOnlyCertThumbprint = AppOnlyCertThumbprint,
};
}
+12
View File
@@ -0,0 +1,12 @@
namespace SharepointToolbox.Web.Core.Models;
/// <summary>
/// Outcome of a user-access audit run. Carries the matched access entries plus per-site
/// scan tallies so the UI can report how many sites were skipped for no access or failed
/// on a non-access error (a tenant-wide scan continues past both).
/// </summary>
public record UserAccessAuditResult(
IReadOnlyList<UserAccessEntry> Entries,
int SitesScanned,
int SitesDenied,
int SitesFailed);
+25 -3
View File
@@ -1,18 +1,40 @@
FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS base
# Base images pinned to exact patch for reproducible builds. Floating `:10.0` tags
# drift between machines; bump deliberately. (SDK 10.0.203 + runtime 10.0.8.)
FROM mcr.microsoft.com/dotnet/aspnet:10.0.8 AS base
WORKDIR /app
EXPOSE 8080
# curl for the compose healthcheck (aspnet image ships no wget/curl).
RUN apt-get update \
&& apt-get install -y --no-install-recommends curl \
&& rm -rf /var/lib/apt/lists/*
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
FROM mcr.microsoft.com/dotnet/sdk:10.0.203 AS build
WORKDIR /src
COPY ["SharepointToolbox.Web.csproj", "."]
RUN dotnet restore
COPY . .
RUN dotnet publish -c Release -o /app/publish --no-restore
# Do NOT add --no-restore here. The restore above runs with only the .csproj present
# (no source, no wwwroot); pairing that cached state with `publish --no-restore`
# silently drops the Blazor framework static assets (wwwroot/_framework/blazor.web.js)
# from the output → the boot script 404s and no interactive circuit starts on any page.
# Letting publish restore against the full project re-materializes them. (Reproduced;
# the early restore above is kept only to cache the NuGet layer.)
RUN dotnet publish -c Release -o /app/publish
FROM base AS final
WORKDIR /app
COPY --from=build /app/publish .
# Run as the non-root `app` user shipped in the aspnet image (UID 1654) instead of root.
# /data holds the crown jewels (Data Protection keys, app-only certs, the user store), so
# create it owned by `app` with 0700 before declaring the volume — Docker seeds a fresh
# named volume from the image path's ownership/mode, so the running user can write it and
# other host users can't read the keys/certs at rest.
RUN mkdir -p /data \
&& chown -R app:app /app /data \
&& chmod 700 /data
USER app
# Volume for persistent data (profiles, settings, templates, logs, exports)
VOLUME ["/data"]
+70
View File
@@ -0,0 +1,70 @@
using System.Security.Cryptography.X509Certificates;
using Microsoft.AspNetCore.DataProtection;
namespace SharepointToolbox.Web.Infrastructure.Auth;
/// <summary>
/// File-backed <see cref="IAppOnlyCertStore"/>. Each profile's certificate is
/// re-exported password-less, encrypted with ASP.NET Core Data Protection, and
/// written to {certsFolder}/{profileId}.bin. The uploaded PFX password is consumed
/// at save time and never persisted.
/// </summary>
public class AppOnlyCertStore : IAppOnlyCertStore
{
private const string Purpose = "SharepointToolbox.AppOnlyCert.v1";
private readonly string _certsFolder;
private readonly IDataProtector _protector;
public AppOnlyCertStore(string certsFolder, IDataProtectionProvider dataProtection)
{
_certsFolder = certsFolder;
_protector = dataProtection.CreateProtector(Purpose);
Directory.CreateDirectory(_certsFolder);
}
private string PathFor(string profileId) => Path.Combine(_certsFolder, $"{profileId}.bin");
public async Task<string> SaveAsync(string profileId, byte[] pfxBytes, string? password, CancellationToken ct = default)
{
// Open the uploaded PFX (Exportable so we can re-emit a password-less copy that
// the loader can open later without prompting). EphemeralKeySet keeps the key
// out of the Windows certificate store during this transient operation.
using var cert = X509CertificateLoader.LoadPkcs12(
pfxBytes, password,
X509KeyStorageFlags.Exportable | X509KeyStorageFlags.EphemeralKeySet);
if (!cert.HasPrivateKey)
throw new InvalidOperationException("The uploaded certificate has no private key. Export the PFX with its key.");
var passwordless = cert.Export(X509ContentType.Pkcs12);
var protectedBytes = _protector.Protect(passwordless);
Directory.CreateDirectory(_certsFolder);
var tmp = PathFor(profileId) + ".tmp";
await File.WriteAllBytesAsync(tmp, protectedBytes, ct);
File.Move(tmp, PathFor(profileId), overwrite: true);
return cert.Thumbprint;
}
public async Task<X509Certificate2?> LoadAsync(string profileId, CancellationToken ct = default)
{
var path = PathFor(profileId);
if (!File.Exists(path)) return null;
var protectedBytes = await File.ReadAllBytesAsync(path, ct);
var pfx = _protector.Unprotect(protectedBytes);
return X509CertificateLoader.LoadPkcs12(
pfx, password: null,
X509KeyStorageFlags.Exportable | X509KeyStorageFlags.EphemeralKeySet);
}
public bool Exists(string profileId) => File.Exists(PathFor(profileId));
public void Delete(string profileId)
{
var path = PathFor(profileId);
if (File.Exists(path)) File.Delete(path);
}
}
@@ -0,0 +1,120 @@
using Azure.Core;
using Azure.Identity;
using Microsoft.Graph;
using Microsoft.SharePoint.Client;
using SharepointToolbox.Web.Core.Models;
namespace SharepointToolbox.Web.Infrastructure.Auth;
/// <summary>
/// Certificate-based app-only client factory. Acquires tokens with
/// <see cref="ClientCertificateCredential"/> and injects the SharePoint bearer token
/// through CSOM's <c>ExecutingWebRequest</c> hook — the same mechanism the delegated
/// <c>SessionManager</c> uses, so report services see an ordinary authenticated
/// <see cref="ClientContext"/> regardless of which auth model produced it.
/// </summary>
public class AppOnlyContextFactory : IAppOnlyContextFactory
{
private static readonly string[] GraphScopes = ["https://graph.microsoft.com/.default"];
private readonly IAppOnlyCertStore _certStore;
public AppOnlyContextFactory(IAppOnlyCertStore certStore) { _certStore = certStore; }
public bool IsConfigured(TenantProfile profile) =>
profile.AppOnlyEnabled
&& !string.IsNullOrWhiteSpace(profile.AppOnlyClientId)
&& !string.IsNullOrWhiteSpace(profile.TenantId)
&& _certStore.Exists(profile.Id);
public async Task<AccessToken> GetTokenAsync(TenantProfile profile, string scope, CancellationToken ct = default)
{
var credential = await BuildCredentialAsync(profile, ct);
return await credential.GetTokenAsync(new TokenRequestContext([scope]), ct);
}
public async Task<ClientContext> CreateContextAsync(TenantProfile profile, string siteUrl, CancellationToken ct = default)
{
var credential = await BuildCredentialAsync(profile, ct);
var spScope = SharePointScope(siteUrl);
var ctx = new ClientContext(siteUrl);
ctx.ExecutingWebRequest += (_, e) =>
{
// CSOM raises this synchronously immediately before sending; acquire the
// token synchronously so the header is guaranteed set. ClientCertificateCredential
// caches access tokens internally, so this is cheap after the first call.
var token = credential.GetToken(new TokenRequestContext([spScope]), CancellationToken.None);
e.WebRequestExecutor.RequestHeaders["Authorization"] = "Bearer " + token.Token;
};
return ctx;
}
public async Task<GraphServiceClient> CreateGraphClientAsync(TenantProfile profile, CancellationToken ct = default)
{
var credential = await BuildCredentialAsync(profile, ct);
return new GraphServiceClient(credential, GraphScopes);
}
public async Task<string?> TestConnectionAsync(TenantProfile profile, CancellationToken ct = default)
{
try
{
using var ctx = await CreateContextAsync(profile, profile.TenantUrl, ct);
ctx.Load(ctx.Web, w => w.Title);
await ctx.ExecuteQueryAsync();
return null;
}
catch (Exception ex)
{
return ex.Message;
}
}
public async Task<string?> WaitUntilReadyAsync(TenantProfile profile, TimeSpan timeout, CancellationToken ct = default)
{
var deadline = DateTimeOffset.UtcNow + timeout;
var delay = TimeSpan.FromSeconds(5);
string? lastError;
do
{
// Each attempt builds a fresh credential, so a cached 401 never sticks across retries.
lastError = await TestConnectionAsync(profile, ct);
if (lastError is null) return null;
if (DateTimeOffset.UtcNow + delay >= deadline) break;
await Task.Delay(delay, ct);
}
while (DateTimeOffset.UtcNow < deadline);
return lastError;
}
private async Task<ClientCertificateCredential> BuildCredentialAsync(TenantProfile profile, CancellationToken ct)
{
if (!profile.AppOnlyEnabled)
throw new InvalidOperationException($"App-only reports are not enabled for client '{profile.Name}'.");
if (string.IsNullOrWhiteSpace(profile.AppOnlyClientId))
throw new InvalidOperationException($"No app-only client ID configured for client '{profile.Name}'.");
if (string.IsNullOrWhiteSpace(profile.TenantId))
throw new InvalidOperationException($"No tenant ID configured for client '{profile.Name}'.");
var cert = await _certStore.LoadAsync(profile.Id, ct)
?? throw new InvalidOperationException($"No app-only certificate stored for client '{profile.Name}'.");
var options = new ClientCertificateCredentialOptions
{
// SharePoint app-only requires the v1 resource audience; SendCertificateChain
// improves compatibility with subject-name/issuer-configured app registrations.
SendCertificateChain = true
};
return new ClientCertificateCredential(profile.TenantId, profile.AppOnlyClientId, cert, options);
}
// https://contoso.sharepoint.com/sites/Foo → https://contoso.sharepoint.com/.default
private static string SharePointScope(string siteUrl)
{
if (Uri.TryCreate(siteUrl, UriKind.Absolute, out var uri))
return $"{uri.Scheme}://{uri.Host}/.default";
return siteUrl.TrimEnd('/') + "/.default";
}
}
+14 -2
View File
@@ -5,22 +5,34 @@ using SharepointToolbox.Web.Services.Session;
namespace SharepointToolbox.Web.Infrastructure.Auth;
/// <summary>Delegated Graph client using OAuth2 refresh-token flow via ISessionManager.</summary>
/// <summary>
/// Builds a Graph client for a profile. Certificate-configured profiles get an app-only
/// client (no interactive sign-in); all others use the delegated OAuth2 refresh-token flow
/// via ISessionManager.
/// </summary>
public class GraphClientFactory
{
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<GraphServiceClient> 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(
+24
View File
@@ -0,0 +1,24 @@
using System.Security.Cryptography.X509Certificates;
namespace SharepointToolbox.Web.Infrastructure.Auth;
/// <summary>
/// Stores the per-client app-only certificate (private key included) encrypted at
/// rest, keyed by profile id. Used only by the background scheduler — never exposed
/// to the browser.
/// </summary>
public interface IAppOnlyCertStore
{
/// <summary>
/// Persists an uploaded PFX for a profile. Returns the certificate thumbprint.
/// The uploaded password is used only to open the PFX; it is not retained.
/// </summary>
Task<string> SaveAsync(string profileId, byte[] pfxBytes, string? password, CancellationToken ct = default);
/// <summary>Loads the stored certificate (with private key) for app-only auth, or null if none.</summary>
Task<X509Certificate2?> LoadAsync(string profileId, CancellationToken ct = default);
bool Exists(string profileId);
void Delete(string profileId);
}
@@ -0,0 +1,47 @@
using Azure.Core;
using Microsoft.Graph;
using Microsoft.SharePoint.Client;
using SharepointToolbox.Web.Core.Models;
namespace SharepointToolbox.Web.Infrastructure.Auth;
/// <summary>
/// Builds app-only (certificate-based) clients for a client profile. Drives BOTH the
/// background report scheduler AND the interactive session: when a profile is configured
/// for certificate auth (see <see cref="IsConfigured"/>), technicians operate through the
/// app identity and never sign in to SharePoint per profile. Requires
/// <see cref="TenantProfile.AppOnlyEnabled"/>, an app-only client id, and a stored certificate.
/// </summary>
public interface IAppOnlyContextFactory
{
/// <summary>
/// True when this profile can authenticate app-only without an interactive sign-in:
/// <see cref="TenantProfile.AppOnlyEnabled"/> is set, an app-only client id is present,
/// and a certificate is stored for the profile. When false, callers fall back to the
/// delegated refresh-token flow.
/// </summary>
bool IsConfigured(TenantProfile profile);
/// <summary>CSOM context for a specific site, authenticated app-only.</summary>
Task<ClientContext> CreateContextAsync(TenantProfile profile, string siteUrl, CancellationToken ct = default);
/// <summary>Microsoft Graph client, authenticated app-only.</summary>
Task<GraphServiceClient> CreateGraphClientAsync(TenantProfile profile, CancellationToken ct = default);
/// <summary>Acquires an app-only access token for an arbitrary scope (e.g. a SharePoint host or Graph).</summary>
Task<AccessToken> GetTokenAsync(TenantProfile profile, string scope, CancellationToken ct = default);
/// <summary>
/// Verifies the stored credentials can authenticate against the tenant root web.
/// Returns null on success, or an error message describing the failure.
/// </summary>
Task<string?> TestConnectionAsync(TenantProfile profile, CancellationToken ct = default);
/// <summary>
/// Polls <see cref="TestConnectionAsync"/> until it succeeds or <paramref name="timeout"/>
/// elapses. After a fresh app registration, the certificate key credential and app-role
/// admin consent take time to propagate through Entra (token requests 401 until then);
/// this waits that window out. Returns null once ready, or the last error on timeout.
/// </summary>
Task<string?> WaitUntilReadyAsync(TenantProfile profile, TimeSpan timeout, CancellationToken ct = default);
}
+31 -14
View File
@@ -7,23 +7,31 @@ using SharepointToolbox.Web.Services.Session;
namespace SharepointToolbox.Web.Infrastructure.Auth;
/// <summary>
/// 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 (<see cref="IAppOnlyContextFactory.IsConfigured"/>), 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.
/// </summary>
public class SessionManager : ISessionManager
{
private readonly ISessionCredentialStore _credentialStore;
private readonly ITokenRefreshService _tokenRefresh;
private readonly IAppOnlyContextFactory _appOnly;
private readonly Dictionary<string, ClientContext> _contexts = new();
private readonly Dictionary<string, (string Token, DateTimeOffset ExpiresAt)> _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<ClientContext> 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)
+27 -7
View File
@@ -4,6 +4,7 @@ using System.Text.Json;
using Microsoft.AspNetCore.WebUtilities;
using Microsoft.Extensions.Options;
using SharepointToolbox.Web.Core.Config;
using SharepointToolbox.Web.Core.Helpers;
using SharepointToolbox.Web.Core.Models;
using SharepointToolbox.Web.Infrastructure.Persistence;
using SharepointToolbox.Web.Services.Auth;
@@ -51,7 +52,10 @@ public static class OAuthEndpoints
TenantId = profile.TenantId,
ClientId = profile.ClientId,
SpHost = ExtractHost(profile.TenantUrl),
ReturnUrl = string.IsNullOrEmpty(returnUrl) ? "/" : returnUrl,
// Constrain to a site-relative path: the callback appends the redeemable
// token_key to this URL, so an external returnUrl would leak the client's
// SharePoint refresh token off-domain.
ReturnUrl = returnUrl.ToLocalReturnUrl(),
IsRegistration = false,
});
@@ -68,12 +72,18 @@ public static class OAuthEndpoints
string? error_description,
IOAuthFlowCache flowCache,
IOptions<ClientConnectOptions> opts,
IHttpClientFactory httpClientFactory) =>
IHttpClientFactory httpClientFactory,
ILoggerFactory loggerFactory) =>
{
var log = loggerFactory.CreateLogger("SharepointToolbox.Web.OAuth.Connect");
if (!string.IsNullOrEmpty(error))
{
var errMsg = Uri.EscapeDataString(error_description ?? error);
return Results.Redirect($"/?connect_error={errMsg}");
// The provider's verbose error_description can carry correlation/trace ids and
// lands in the URL bar + proxy access logs. Log it server-side; surface only the
// short, safe OAuth error code (e.g. "access_denied") to the browser.
log.LogWarning("Connect callback returned error {Error}: {Description}", error, error_description);
return Results.Redirect($"/?connect_error={Uri.EscapeDataString(error)}");
}
if (string.IsNullOrEmpty(code) || string.IsNullOrEmpty(state))
@@ -103,14 +113,24 @@ public static class OAuthEndpoints
if (!resp.IsSuccessStatusCode)
{
var msg = Uri.EscapeDataString($"Token exchange failed: {json}");
return Results.Redirect($"/?connect_error={msg}");
// The raw token-endpoint body can contain trace ids / claim hints — keep it out of
// the URL and the proxy logs. Record it server-side, redirect with a generic notice.
log.LogWarning("Token exchange failed ({Status}): {Body}", resp.StatusCode, json);
return Results.Redirect($"/?connect_error={Uri.EscapeDataString("Token exchange failed. Please try connecting again.")}");
}
using var doc = JsonDocument.Parse(json);
var root = doc.RootElement;
var upn = ExtractUpnFromIdToken(root);
var refreshToken = root.GetProperty("refresh_token").GetString()!;
// offline_access should yield a refresh_token; if the tenant/app withheld it the
// session can't be persisted. Fail cleanly instead of throwing a 500 + stack trace.
if (!root.TryGetProperty("refresh_token", out var refreshTokenEl) ||
refreshTokenEl.GetString() is not { Length: > 0 } refreshToken)
{
log.LogWarning("Token response had no refresh_token for tenant {Tenant}.", flowState.TenantId);
return Results.Redirect($"/?connect_error={Uri.EscapeDataString("Sign-in did not return a refresh token. Please try again.")}");
}
var tokens = new SessionTokens
{
@@ -0,0 +1,87 @@
using System.Text;
using System.Text.Json;
using SharepointToolbox.Web.Core.Models;
namespace SharepointToolbox.Web.Infrastructure.Persistence;
/// <summary>
/// JSON-file index of produced report files (reports-index.json). The files
/// themselves live under {ExportsFolder}/{ProfileId}/; this is the catalogue the
/// per-client "Reports" list and the id-based download endpoint read.
/// </summary>
public class GeneratedReportRepository
{
private readonly string _filePath;
private readonly SemaphoreSlim _writeLock = new(1, 1);
private static readonly JsonSerializerOptions ReadOpts = new() { PropertyNameCaseInsensitive = true };
private static readonly JsonSerializerOptions WriteOpts = new()
{
WriteIndented = true,
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
};
public GeneratedReportRepository(string filePath) { _filePath = filePath; }
public async Task<IReadOnlyList<GeneratedReport>> LoadAsync()
{
if (!File.Exists(_filePath)) return Array.Empty<GeneratedReport>();
string json;
try { json = await File.ReadAllTextAsync(_filePath, Encoding.UTF8); }
catch (IOException ex) { throw new InvalidDataException($"Failed to read report index: {_filePath}", ex); }
Root? root;
try { root = JsonSerializer.Deserialize<Root>(json, ReadOpts); }
catch (JsonException ex) { throw new InvalidDataException($"Invalid JSON in report index: {_filePath}", ex); }
return (IReadOnlyList<GeneratedReport>?)root?.Reports ?? Array.Empty<GeneratedReport>();
}
public async Task<GeneratedReport?> GetAsync(string id)
=> (await LoadAsync()).FirstOrDefault(r => r.Id == id);
public async Task<IReadOnlyList<GeneratedReport>> LoadForProfileAsync(string profileId)
{
var all = await LoadAsync();
return all.Where(r => r.ProfileId == profileId)
.OrderByDescending(r => r.GeneratedUtc)
.ToList();
}
public async Task AddAsync(GeneratedReport report)
{
await _writeLock.WaitAsync();
try
{
var list = (await LoadAsync()).ToList();
list.Add(report);
await WriteUnlockedAsync(list);
}
finally { _writeLock.Release(); }
}
public async Task DeleteAsync(string id)
{
await _writeLock.WaitAsync();
try
{
var list = (await LoadAsync()).ToList();
list.RemoveAll(r => r.Id == id);
await WriteUnlockedAsync(list);
}
finally { _writeLock.Release(); }
}
private async Task WriteUnlockedAsync(IReadOnlyList<GeneratedReport> reports)
{
var json = JsonSerializer.Serialize(new Root { Reports = reports.ToList() }, WriteOpts);
var tmpPath = _filePath + ".tmp";
var dir = Path.GetDirectoryName(_filePath);
if (!string.IsNullOrEmpty(dir)) Directory.CreateDirectory(dir);
await File.WriteAllTextAsync(tmpPath, json, Encoding.UTF8);
JsonDocument.Parse(await File.ReadAllTextAsync(tmpPath, Encoding.UTF8)).Dispose();
File.Move(tmpPath, _filePath, overwrite: true);
}
private sealed class Root { public List<GeneratedReport> Reports { get; set; } = new(); }
}
@@ -0,0 +1,91 @@
using System.Text;
using System.Text.Json;
using SharepointToolbox.Web.Core.Models;
namespace SharepointToolbox.Web.Infrastructure.Persistence;
/// <summary>
/// JSON-file store for <see cref="ScheduledReport"/> definitions (schedules.json).
/// Mirrors <see cref="ProfileRepository"/>'s atomic temp-file-then-move write.
/// Mutating helpers (<see cref="UpsertAsync"/>/<see cref="DeleteAsync"/>) perform the
/// read-modify-write under the same lock so concurrent callers don't clobber each other.
/// </summary>
public class ScheduledReportRepository
{
private readonly string _filePath;
private readonly SemaphoreSlim _writeLock = new(1, 1);
private static readonly JsonSerializerOptions ReadOpts = new() { PropertyNameCaseInsensitive = true };
private static readonly JsonSerializerOptions WriteOpts = new()
{
WriteIndented = true,
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
};
public ScheduledReportRepository(string filePath) { _filePath = filePath; }
public async Task<IReadOnlyList<ScheduledReport>> LoadAsync()
{
if (!File.Exists(_filePath)) return Array.Empty<ScheduledReport>();
string json;
try { json = await File.ReadAllTextAsync(_filePath, Encoding.UTF8); }
catch (IOException ex) { throw new InvalidDataException($"Failed to read schedules: {_filePath}", ex); }
Root? root;
try { root = JsonSerializer.Deserialize<Root>(json, ReadOpts); }
catch (JsonException ex) { throw new InvalidDataException($"Invalid JSON in schedules: {_filePath}", ex); }
return (IReadOnlyList<ScheduledReport>?)root?.Schedules ?? Array.Empty<ScheduledReport>();
}
public async Task<IReadOnlyList<ScheduledReport>> LoadForProfileAsync(string profileId)
{
var all = await LoadAsync();
return all.Where(s => s.ProfileId == profileId).ToList();
}
public async Task SaveAllAsync(IReadOnlyList<ScheduledReport> schedules)
{
await _writeLock.WaitAsync();
try { await WriteUnlockedAsync(schedules); }
finally { _writeLock.Release(); }
}
public async Task UpsertAsync(ScheduledReport schedule)
{
await _writeLock.WaitAsync();
try
{
var list = (await LoadAsync()).ToList();
var idx = list.FindIndex(s => s.Id == schedule.Id);
if (idx >= 0) list[idx] = schedule; else list.Add(schedule);
await WriteUnlockedAsync(list);
}
finally { _writeLock.Release(); }
}
public async Task DeleteAsync(string id)
{
await _writeLock.WaitAsync();
try
{
var list = (await LoadAsync()).ToList();
list.RemoveAll(s => s.Id == id);
await WriteUnlockedAsync(list);
}
finally { _writeLock.Release(); }
}
private async Task WriteUnlockedAsync(IReadOnlyList<ScheduledReport> schedules)
{
var json = JsonSerializer.Serialize(new Root { Schedules = schedules.ToList() }, WriteOpts);
var tmpPath = _filePath + ".tmp";
var dir = Path.GetDirectoryName(_filePath);
if (!string.IsNullOrEmpty(dir)) Directory.CreateDirectory(dir);
await File.WriteAllTextAsync(tmpPath, json, Encoding.UTF8);
JsonDocument.Parse(await File.ReadAllTextAsync(tmpPath, Encoding.UTF8)).Dispose();
File.Move(tmpPath, _filePath, overwrite: true);
}
private sealed class Root { public List<ScheduledReport> Schedules { get; set; } = new(); }
}
+39 -1
View File
@@ -924,6 +924,33 @@ Cet onglet fait l'inverse : vous sélectionnez un ou plusieurs utilisateurs et i
<data name="audit.chip.high" xml:space="preserve">
<value>Élevé</value>
</data>
<data name="audit.view.bySite" xml:space="preserve">
<value>Par site</value>
</data>
<data name="audit.view.table" xml:space="preserve">
<value>Tableau</value>
</data>
<data name="audit.bysite.hint" xml:space="preserve">
<value>Sites auxquels le(s) utilisateur(s) sélectionné(s) ont accès. Cliquez sur un site pour afficher le détail des permissions.</value>
</data>
<data name="audit.hint.allSites" xml:space="preserve">
<value>Facultatif — laissez vide pour analyser tous les sites du locataire.</value>
</data>
<data name="audit.status.discoveringSites" xml:space="preserve">
<value>Découverte de tous les sites du locataire…</value>
</data>
<data name="audit.err.discoverFailed" xml:space="preserve">
<value>Impossible de lister les sites du locataire : {0}</value>
</data>
<data name="audit.scan.sitesScanned" xml:space="preserve">
<value>{0} site(s) analysé(s)</value>
</data>
<data name="audit.scan.sitesDenied" xml:space="preserve">
<value>{0} ignoré(s) (aucun accès)</value>
</data>
<data name="audit.scan.sitesFailed" xml:space="preserve">
<value>{0} en échec</value>
</data>
<data name="audit.chk.includeInherited" xml:space="preserve">
<value>Inclure les autorisations héritées</value>
</data>
@@ -1326,6 +1353,9 @@ Cet onglet fait l'inverse : vous sélectionnez un ou plusieurs utilisateurs et i
<data name="nav.reconnect" xml:space="preserve">
<value>Reconnecter</value>
</data>
<data name="nav.appIdentity" xml:space="preserve">
<value>identité application</value>
</data>
<data name="nav.searchPlaceholder" xml:space="preserve">
<value>Rechercher…</value>
</data>
@@ -1497,6 +1527,12 @@ Cet onglet fait l'inverse : vous sélectionnez un ou plusieurs utilisateurs et i
<data name="profiles.reg.registered" xml:space="preserve">
<value>Application inscrite. Vérifiez et enregistrez le profil.</value>
</data>
<data name="profiles.reg.propagating" xml:space="preserve">
<value>Application inscrite. Propagation du certificat et du consentement en cours…</value>
</data>
<data name="profiles.reg.notready" xml:space="preserve">
<value>Application inscrite, mais l'authentification app-only n'est pas encore prête ({0}). Cela peut prendre quelques minutes ; enregistrez puis utilisez « Tester la connexion » sous peu.</value>
</data>
<data name="profiles.reg.requesting" xml:space="preserve">
<value>Demande d'un code de connexion…</value>
</data>
@@ -1528,7 +1564,7 @@ Cet onglet fait l'inverse : vous sélectionnez un ou plusieurs utilisateurs et i
<value>Sélectionné</value>
</data>
<data name="profiles.subtitle" xml:space="preserve">
<value>Gérez les connexions aux tenants SharePoint. Les identifiants sont saisis par session — aucun secret n'est stocké sur le disque.</value>
<value>Gérez les connexions aux tenants SharePoint. Les identifiants sont saisis par session.</value>
</data>
<data name="profiles.tenantid.label" xml:space="preserve">
<value>ID de tenant :</value>
@@ -1887,4 +1923,6 @@ Cet onglet fait l'inverse : vous sélectionnez un ou plusieurs utilisateurs et i
<data name="help.userType" xml:space="preserve"><value>Membre = un compte interne à votre organisation ; Invité = un utilisateur externe invité d'une autre organisation.</value></data>
<data name="help.templateCapture" xml:space="preserve"><value>La capture enregistre la structure d'un site (bibliothèques, dossiers, groupes de permissions) comme modèle réutilisable pour créer de nouveaux sites.</value></data>
<data name="help.permissionGroups" xml:space="preserve"><value>Les groupes de permissions du site (Propriétaires, Membres, Visiteurs) et leurs membres, afin de recréer la même configuration d'accès.</value></data>
<data name="nav.scheduledReports" xml:space="preserve"><value>Rapports planifiés</value></data>
<data name="nav.reports" xml:space="preserve"><value>Rapports</value></data>
</root>
+39 -1
View File
@@ -924,6 +924,33 @@ This tab does the reverse: you select one or more users and it finds every objec
<data name="audit.chip.high" xml:space="preserve">
<value>High</value>
</data>
<data name="audit.view.bySite" xml:space="preserve">
<value>By site</value>
</data>
<data name="audit.view.table" xml:space="preserve">
<value>Table</value>
</data>
<data name="audit.bysite.hint" xml:space="preserve">
<value>Sites the selected user(s) can access. Click a site to reveal the permission detail.</value>
</data>
<data name="audit.hint.allSites" xml:space="preserve">
<value>Optional — leave empty to scan every site in the tenant.</value>
</data>
<data name="audit.status.discoveringSites" xml:space="preserve">
<value>Discovering all sites in the tenant…</value>
</data>
<data name="audit.err.discoverFailed" xml:space="preserve">
<value>Could not list tenant sites: {0}</value>
</data>
<data name="audit.scan.sitesScanned" xml:space="preserve">
<value>{0} site(s) scanned</value>
</data>
<data name="audit.scan.sitesDenied" xml:space="preserve">
<value>{0} skipped (no access)</value>
</data>
<data name="audit.scan.sitesFailed" xml:space="preserve">
<value>{0} failed</value>
</data>
<data name="audit.chk.includeInherited" xml:space="preserve">
<value>Include inherited</value>
</data>
@@ -1326,6 +1353,9 @@ This tab does the reverse: you select one or more users and it finds every objec
<data name="nav.reconnect" xml:space="preserve">
<value>Reconnect</value>
</data>
<data name="nav.appIdentity" xml:space="preserve">
<value>app identity</value>
</data>
<data name="nav.searchPlaceholder" xml:space="preserve">
<value>Search…</value>
</data>
@@ -1497,6 +1527,12 @@ This tab does the reverse: you select one or more users and it finds every objec
<data name="profiles.reg.registered" xml:space="preserve">
<value>App registered. Review and Save the profile.</value>
</data>
<data name="profiles.reg.propagating" xml:space="preserve">
<value>App registered. Waiting for certificate and consent to propagate…</value>
</data>
<data name="profiles.reg.notready" xml:space="preserve">
<value>App registered, but app-only auth is not ready yet ({0}). It may take a few minutes; Save and use Test connection shortly.</value>
</data>
<data name="profiles.reg.requesting" xml:space="preserve">
<value>Requesting a sign-in code…</value>
</data>
@@ -1528,7 +1564,7 @@ This tab does the reverse: you select one or more users and it finds every objec
<value>Selected</value>
</data>
<data name="profiles.subtitle" xml:space="preserve">
<value>Manage SharePoint tenant connections. Credentials are entered per session — no secrets stored on disk.</value>
<value>Manage SharePoint tenant connections. Credentials are entered per session.</value>
</data>
<data name="profiles.tenantid.label" xml:space="preserve">
<value>Tenant ID:</value>
@@ -1887,4 +1923,6 @@ This tab does the reverse: you select one or more users and it finds every objec
<data name="help.userType" xml:space="preserve"><value>Member = an account inside your organization; Guest = an external user invited from another organization.</value></data>
<data name="help.templateCapture" xml:space="preserve"><value>Capturing saves a site's structure (libraries, folders, permission groups) as a reusable template you can later apply to create new sites.</value></data>
<data name="help.permissionGroups" xml:space="preserve"><value>The site's permission groups (Owners, Members, Visitors) and their members, so the same access setup can be recreated.</value></data>
<data name="nav.scheduledReports" xml:space="preserve"><value>Scheduled Reports</value></data>
<data name="nav.reports" xml:space="preserve"><value>Reports</value></data>
</root>
+268 -21
View File
@@ -3,11 +3,16 @@ 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.HttpOverrides;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.RateLimiting;
using System.Threading.RateLimiting;
using Microsoft.Extensions.Options;
using Microsoft.IdentityModel.Protocols.OpenIdConnect;
using Serilog;
using SharepointToolbox.Web.Core.Config;
using SharepointToolbox.Web.Core.Helpers;
using SharepointToolbox.Web.Core.Models;
using SharepointToolbox.Web.Infrastructure.Auth;
using SharepointToolbox.Web.Infrastructure.OAuth;
@@ -17,6 +22,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);
@@ -41,9 +47,37 @@ builder.Services.AddRazorComponents()
.AddInteractiveServerComponents();
builder.Services.AddHttpContextAccessor();
// ── Forwarded headers ─────────────────────────────────────────────────────────
// Behind a TLS-terminating reverse proxy the app receives plain HTTP; without this
// it would see scheme=http and build an http:// OIDC redirect_uri (which Entra
// rejects for non-localhost hosts) and set the auth cookie non-Secure. Honour
// X-Forwarded-Proto/For so the app sees the real https scheme + client IP. Proxy IP
// is unknown inside the container network, so don't restrict to known proxies.
builder.Services.Configure<ForwardedHeadersOptions>(options =>
{
options.ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto;
options.KnownIPNetworks.Clear();
options.KnownProxies.Clear();
});
// ── Data Protection ───────────────────────────────────────────────────────────
// Keys MUST persist across container recreates: they encrypt the auth cookie AND
// the app-only certs on disk (/data/appcerts). Default storage is the container's
// ephemeral home dir, so a redeploy would log everyone out and make stored certs
// undecryptable. Pin keys + app name to the data volume.
builder.Services.AddDataProtection()
.PersistKeysToFileSystem(new DirectoryInfo(Path.Combine(dataFolder, "dpkeys")))
.SetApplicationName("SharepointToolbox.Web");
// Localization string source — Scoped: one per circuit, with its own explicit culture.
builder.Services.AddScoped<SharepointToolbox.Web.Localization.TranslationSource>();
// ── Public domain ─────────────────────────────────────────────────────────────
// App__Domain (e.g. sptb.example.com) drives both OIDC sign-in (below) and the
// SharePoint-connect redirect URI. Bound once here so both consumers share it.
var appDomain = new AppDomainOptions();
builder.Configuration.GetSection("App").Bind(appDomain);
// ── Authentication ────────────────────────────────────────────────────────────
if (builder.Environment.IsDevelopment())
{
@@ -63,8 +97,13 @@ else
{
builder.Services.AddAuthentication(options =>
{
options.DefaultScheme = CookieAuthenticationDefaults.AuthenticationScheme;
options.DefaultChallengeScheme = OpenIdConnectDefaults.AuthenticationScheme;
options.DefaultScheme = CookieAuthenticationDefaults.AuthenticationScheme;
// Challenge the cookie scheme (→ redirect to /account/login, the combined
// local + Microsoft page). OIDC is triggered explicitly from the "Sign in
// with Microsoft" button (/account/login/entra), never as the implicit
// challenge — otherwise logged-out hits on protected pages force OIDC and
// 404 when it is unconfigured/unreachable.
options.DefaultChallengeScheme = CookieAuthenticationDefaults.AuthenticationScheme;
})
.AddCookie(options =>
{
@@ -73,18 +112,33 @@ else
// Auth state lives entirely in the browser cookie (Data Protection encrypted)
options.SessionStore = null;
options.Cookie.SameSite = SameSiteMode.Lax;
options.Cookie.SecurePolicy = CookieSecurePolicy.SameAsRequest;
// Always mark the auth cookie Secure in prod. The app sits behind a TLS-terminating
// proxy and forwarded headers are trusted from any source (proxy IP is unknown inside
// the container network), so SameAsRequest would let a spoofed X-Forwarded-Proto: http
// — or any direct plaintext hit — emit a non-Secure cookie. Always wins regardless.
options.Cookie.SecurePolicy = CookieSecurePolicy.Always;
options.ExpireTimeSpan = TimeSpan.FromHours(8);
options.SlidingExpiration = true;
})
.AddOpenIdConnect(options =>
{
var oidc = builder.Configuration.GetSection("Oidc");
options.Authority = $"https://login.microsoftonline.com/{oidc["TenantId"]}/v2.0";
options.ClientId = oidc["ClientId"];
options.ClientSecret = oidc["ClientSecret"];
// Strip accidental surrounding quotes/whitespace. docker-compose's `environment` list form
// (`- Oidc__TenantId="<guid>"`) embeds the literal quotes in the value, producing a malformed
// Authority (…/"<tenant>"/v2.0) that fails metadata discovery with IDX20803. Same trap on the
// secret would silently break the token exchange. Trim defensively.
static string Clean(string? v) => v?.Trim().Trim('"', '\'') ?? string.Empty;
options.Authority = $"https://login.microsoftonline.com/{Clean(oidc["TenantId"])}/v2.0";
options.ClientId = Clean(oidc["ClientId"]);
options.ClientSecret = Clean(oidc["ClientSecret"]);
options.ResponseType = OpenIdConnectResponseType.Code;
options.SaveTokens = true;
// Do NOT persist the OIDC access/id/refresh tokens in the auth cookie. They are
// never read (SharePoint/Graph auth runs through the separate connect flow +
// app-only cert paths), and storing them bloats the cookie past ~4 KB so it gets
// chunked. The chunked cookie survives the prerender GET but is dropped on the
// WebSocket upgrade that establishes the interactive circuit → the circuit comes
// up anonymous and the app sticks on "Chargement…". Keeping the cookie small fixes it.
options.SaveTokens = false;
options.Scope.Add("openid");
options.Scope.Add("profile");
options.Scope.Add("email");
@@ -92,15 +146,90 @@ else
options.MapInboundClaims = false;
options.TokenValidationParameters.NameClaimType = "preferred_username";
// When App__Domain is set, pin the OIDC redirect_uri (and post-logout redirect) to that
// public host instead of deriving it from the request scheme/host. Keeps sign-in working
// when the app can't see its real external host (no/incorrect forwarded Host header, or
// several hostnames reach the same instance). The value must match the /signin-oidc URI
// registered on the Oidc app. The authorize request and the code→token redemption MUST
// send the identical redirect_uri, so override it in both events.
var oidcRedirectUri = appDomain.BuildUrl(options.CallbackPath.Value ?? "/signin-oidc");
var postLogoutUri = appDomain.BuildUrl(options.SignedOutCallbackPath.Value ?? "/signout-callback-oidc");
if (oidcRedirectUri is not null)
{
options.Events.OnRedirectToIdentityProvider = ctx =>
{
ctx.ProtocolMessage.RedirectUri = oidcRedirectUri;
return Task.CompletedTask;
};
options.Events.OnAuthorizationCodeReceived = ctx =>
{
if (ctx.TokenEndpointRequest is not null)
ctx.TokenEndpointRequest.RedirectUri = oidcRedirectUri;
return Task.CompletedTask;
};
options.Events.OnRedirectToIdentityProviderForSignOut = ctx =>
{
ctx.ProtocolMessage.PostLogoutRedirectUri = postLogoutUri;
return Task.CompletedTask;
};
}
options.Events.OnTokenValidated = async ctx =>
{
var userService = ctx.HttpContext.RequestServices.GetRequiredService<IUserService>();
await userService.ProvisionAsync(ctx.Principal!);
var user = await userService.ProvisionAsync(ctx.Principal!);
// The whole principal is serialized into the auth cookie. The raw OIDC principal carries
// dozens of id_token + userinfo claims (oid, tid, given/family_name, a long picture URL …);
// encrypted + base64 it exceeds ~4 KB, so ChunkingCookieManager splits it into …CookiesC1/C2.
// The chunked cookie survives the prerender GET but is dropped on the Blazor WebSocket upgrade
// → the interactive circuit comes up anonymous → page sticks on "Chargement…". Replace it with
// a slim principal holding only the claims the app reads — identical to the local-login path —
// so the cookie stays small (single, unchunked) and the circuit authenticates. This also adds
// the app_role claim (role-based authz) and auth_provider (logout's OIDC sign-out branch),
// which the fat OIDC principal never had.
var identity = new ClaimsIdentity(
new Claim[]
{
new("preferred_username", user.Email),
new("name", user.DisplayName),
new("app_role", user.Role.ToString()),
new("auth_provider", nameof(AuthProvider.Entra)),
},
ctx.Principal!.Identity!.AuthenticationType,
"preferred_username",
"app_role");
ctx.Principal = new ClaimsPrincipal(identity);
};
});
}
builder.Services.AddAuthorization();
// "Admin" policy checks the app_role claim value directly, rather than [Authorize(Roles=…)]
// — the local/dev sign-in identities don't set a ClaimTypes.Role claim, so a Roles check would
// silently deny local admins. Every identity (OIDC, local, dev) carries app_role.
builder.Services.AddAuthorization(options =>
{
options.AddPolicy("Admin", p => p.RequireClaim("app_role", nameof(UserRole.Admin)));
});
// ── Rate limiting ───────────────────────────────────────────────────────────────
// Volumetric defence on the sign-in endpoints, partitioned by client IP (RemoteIpAddress
// reflects X-Forwarded-For — UseForwardedHeaders runs first). This is the coarse layer;
// the per-account lockout in UserService is what holds up when XFF is spoofed/rotated,
// since forwarded headers are trusted from any source behind the proxy.
builder.Services.AddRateLimiter(options =>
{
options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
options.AddPolicy("login", httpContext =>
RateLimitPartition.GetFixedWindowLimiter(
partitionKey: httpContext.Connection.RemoteIpAddress?.ToString() ?? "unknown",
factory: _ => new FixedWindowRateLimiterOptions
{
PermitLimit = 10,
Window = TimeSpan.FromMinutes(1),
QueueLimit = 0,
}));
});
// ── Memory cache (used by OAuth flow cache) ───────────────────────────────────
builder.Services.AddMemoryCache();
@@ -109,12 +238,28 @@ builder.Services.AddHttpClient("oauth");
// ── ClientConnect options ─────────────────────────────────────────────────────
builder.Services.Configure<ClientConnectOptions>(builder.Configuration.GetSection("ClientConnect"));
// Derive the SharePoint-connect redirect URI from the app's public domain (App__Domain)
// when ClientConnect__RedirectUri isn't set explicitly. Lets a deployment configure a
// single domain (e.g. sptb.example.com) instead of spelling out the full callback URL.
// An explicit RedirectUri still wins, so existing configs are unaffected.
builder.Services.PostConfigure<ClientConnectOptions>(opts =>
{
if (string.IsNullOrWhiteSpace(opts.RedirectUri) &&
appDomain.BuildUrl("/connect/callback") is { } callback)
{
opts.RedirectUri = callback;
}
});
// ── App config ────────────────────────────────────────────────────────────────
var certsFolder = Path.Combine(dataFolder, "appcerts");
builder.Services.Configure<AppConfiguration>(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 +268,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<IAppOnlyCertStore>(sp =>
new AppOnlyCertStore(certsFolder, sp.GetRequiredService<IDataProtectionProvider>()));
builder.Services.AddSingleton<IAppOnlyContextFactory, AppOnlyContextFactory>();
// ── Auth infrastructure ───────────────────────────────────────────────────────
builder.Services.AddSingleton<IPasswordHasher<AppUser>, PasswordHasher<AppUser>>();
@@ -131,6 +283,7 @@ builder.Services.AddSingleton<IOAuthFlowCache, OAuthFlowCache>();
builder.Services.AddSingleton<IEntraDeviceCodeFlow, EntraDeviceCodeFlow>();
builder.Services.AddHttpClient<ITokenRefreshService, TokenRefreshService>();
builder.Services.AddHttpClient<IAppRegistrationService, AppRegistrationService>();
builder.Services.AddSingleton<ICertProvisioningService, CertProvisioningService>();
builder.Services.AddScoped<GraphClientFactory>();
// ── User session (Scoped = one per Blazor circuit = one per browser tab) ─────
@@ -177,8 +330,86 @@ builder.Services.AddScoped<VersionCleanupHtmlExportService>();
builder.Services.AddScoped<BulkResultCsvExportService>();
builder.Services.AddScoped<WebExportService>();
// ── Scheduled reports (background generation) ─────────────────────────────────
builder.Services.AddSingleton<ScheduledRunCoordinator>();
builder.Services.AddScoped<IReportMailService, ReportMailService>();
builder.Services.AddScoped<IScheduledReportRunner, ScheduledReportRunner>();
builder.Services.AddHostedService<ScheduledReportHostedService>();
var app = builder.Build();
// Must run before anything that inspects the request scheme/IP (auth, OIDC, cookies).
app.UseForwardedHeaders();
// When App__Domain is set, rewrite every request's scheme + host to the public domain. The
// framework builds absolute URLs (the cookie login redirect, the OIDC redirect_uri, …) from
// Request.Scheme/Host; behind a proxy that doesn't forward the Host header these are the
// internal host (server IP:port), so loading https://<domain>/ would 302 to http://<ip>:8080.
// Forcing the host here keeps every generated URL on the public domain. Must run before auth.
var publicBaseUri = appDomain.GetBaseUri();
if (publicBaseUri is not null)
{
var publicHost = HostString.FromUriComponent(publicBaseUri);
app.Use((context, next) =>
{
context.Request.Scheme = publicBaseUri.Scheme;
context.Request.Host = publicHost;
return next(context);
});
}
// ── First-run bootstrap ───────────────────────────────────────────────────────
// Seed a local admin when no users exist yet, so a plain-HTTP / LAN deployment that
// can't use Microsoft OIDC (which requires HTTPS + a matching Entra redirect URI) can
// still sign in via the local email/password form. Only fires while the user store is
// empty; set Bootstrap__AdminEmail and Bootstrap__AdminPassword to enable.
{
var bootEmail = app.Configuration["Bootstrap:AdminEmail"];
var bootPass = app.Configuration["Bootstrap:AdminPassword"];
if (!string.IsNullOrWhiteSpace(bootEmail) && !string.IsNullOrWhiteSpace(bootPass))
{
var users = app.Services.GetRequiredService<IUserService>();
// Seed if this email has no account yet — covers both an empty store and a store
// that already holds an Entra-provisioned user from a failed sign-in attempt.
if (await users.GetByEmailAsync(bootEmail) is null)
{
await users.CreateLocalUserAsync(bootEmail, "Administrator", UserRole.Admin, bootPass);
Log.Information("Bootstrap: created local admin {Email}.", bootEmail);
}
else
{
Log.Information("Bootstrap: local admin {Email} already present; skipping seed.", bootEmail);
}
}
}
// ── Security response headers ───────────────────────────────────────────────────
// Defence-in-depth on every response. CSP is tuned to this app: all scripts are external
// (_framework/blazor.web.js, js/app.js) so script-src can stay 'self' with no unsafe-*;
// the login page and the Blazor components use inline <style>/style="" so style-src needs
// 'unsafe-inline'. img-src allows data: for the base64 client logos and CSV download links.
// connect-src 'self' covers the Blazor Server WebSocket. frame-ancestors blocks clickjacking
// of the live circuit (X-Frame-Options for legacy agents).
app.Use(async (context, next) =>
{
var headers = context.Response.Headers;
headers["Content-Security-Policy"] =
"default-src 'self'; " +
"script-src 'self'; " +
"style-src 'self' 'unsafe-inline'; " +
"img-src 'self' data:; " +
"font-src 'self' data:; " +
"connect-src 'self'; " +
"base-uri 'self'; " +
"object-src 'none'; " +
"form-action 'self'; " +
"frame-ancestors 'none'";
headers["X-Frame-Options"] = "DENY";
headers["X-Content-Type-Options"] = "nosniff";
headers["Referrer-Policy"] = "no-referrer";
await next();
});
if (!app.Environment.IsDevelopment())
{
app.UseExceptionHandler("/Error", createScopeForErrors: true);
@@ -188,9 +419,10 @@ if (!app.Environment.IsDevelopment())
// Re-execute unmatched (404) requests into the branded not-found page
app.UseStatusCodePagesWithReExecute("/not-found");
app.UseStaticFiles();
app.MapStaticAssets();
app.UseAuthentication();
app.UseAuthorization();
app.UseRateLimiter();
app.UseAntiforgery();
// ── Login / Logout endpoints ──────────────────────────────────────────────────
@@ -229,7 +461,7 @@ if (isDev)
CookieAuthenticationDefaults.AuthenticationScheme));
await ctx.SignInAsync(CookieAuthenticationDefaults.AuthenticationScheme, principal);
ctx.Response.Redirect(string.IsNullOrEmpty(returnUrl) ? "/" : returnUrl);
ctx.Response.Redirect(returnUrl.ToLocalReturnUrl());
});
}
else
@@ -239,10 +471,10 @@ else
{
var props = new AuthenticationProperties
{
RedirectUri = string.IsNullOrEmpty(returnUrl) ? "/" : returnUrl
RedirectUri = returnUrl.ToLocalReturnUrl()
};
await ctx.ChallengeAsync(OpenIdConnectDefaults.AuthenticationScheme, props);
});
}).RequireRateLimiting("login");
}
// Local password sign-in — available in every environment.
@@ -255,7 +487,7 @@ app.MapPost("/account/local-login", async (HttpContext ctx, IAntiforgery antifor
var email = form["email"].ToString();
var password = form["password"].ToString();
var returnUrl = form["returnUrl"].ToString();
var safeReturn = string.IsNullOrEmpty(returnUrl) || !returnUrl.StartsWith('/') ? "/" : returnUrl;
var safeReturn = returnUrl.ToLocalReturnUrl();
var user = await userService.ValidateLocalCredentialsAsync(email, password);
if (user is null)
@@ -273,7 +505,7 @@ app.MapPost("/account/local-login", async (HttpContext ctx, IAntiforgery antifor
await ctx.SignInAsync(CookieAuthenticationDefaults.AuthenticationScheme, principal);
return Results.Redirect(safeReturn);
});
}).RequireRateLimiting("login");
app.MapGet("/account/logout", async (HttpContext ctx) =>
{
@@ -301,6 +533,21 @@ app.MapGet("/export/download/{fileName}", async (string fileName, IOptions<AppCo
return Results.File(bytes, ct, fileName);
});
// ── Scheduled report download (id-based, scoped to the client's exports subfolder) ──
app.MapGet("/reports/download/{id}", async (string id, GeneratedReportRepository index, IOptions<AppConfiguration> opts, HttpContext ctx) =>
{
if (!ctx.User.Identity?.IsAuthenticated ?? true) return Results.Unauthorized();
var report = await index.GetAsync(id);
if (report is null || report.Status != ReportRunStatus.Success || string.IsNullOrEmpty(report.FileName))
return Results.NotFound();
// ProfileId and FileName are app-generated; GetFileName strips any traversal just in case.
var path = Path.Combine(opts.Value.ExportsFolder, report.ProfileId, Path.GetFileName(report.FileName));
if (!File.Exists(path)) return Results.NotFound();
var bytes = await File.ReadAllBytesAsync(path);
var mime = string.IsNullOrEmpty(report.Mime) ? "application/octet-stream" : report.Mime;
return Results.File(bytes, mime, report.FileName);
});
// ── Audit CSV download ────────────────────────────────────────────────────────
app.MapGet("/audit/export", async (AuditRepository auditRepo, HttpContext ctx) =>
{
@@ -313,13 +560,13 @@ app.MapGet("/audit/export", async (AuditRepository auditRepo, HttpContext ctx) =
sb.AppendLine("Timestamp,UserEmail,UserDisplay,UserRole,Action,Client,Sites,Details");
foreach (var e in entries.OrderByDescending(x => x.Timestamp))
{
string Esc(string v) => v.Contains(',') || v.Contains('"') || v.Contains('\n')
? $"\"{v.Replace("\"", "\"\"")}\"" : v;
// CsvSanitizer neutralizes spreadsheet formula prefixes (= + - @) on top of RFC 4180
// quoting — audited fields (display name, client/site names, details) are user-controlled.
sb.AppendLine(string.Join(",",
Esc(e.Timestamp.ToString("yyyy-MM-dd HH:mm:ss")),
Esc(e.UserEmail), Esc(e.UserDisplay), Esc(e.UserRole.ToString()),
Esc(e.Action), Esc(e.ClientName),
Esc(string.Join("; ", e.Sites)), Esc(e.Details)));
CsvSanitizer.Escape(e.Timestamp.ToString("yyyy-MM-dd HH:mm:ss")),
CsvSanitizer.Escape(e.UserEmail), CsvSanitizer.Escape(e.UserDisplay), CsvSanitizer.Escape(e.UserRole.ToString()),
CsvSanitizer.Escape(e.Action), CsvSanitizer.Escape(e.ClientName),
CsvSanitizer.Escape(string.Join("; ", e.Sites)), CsvSanitizer.Escape(e.Details)));
}
return Results.File(System.Text.Encoding.UTF8.GetBytes(sb.ToString()), "text/csv", "audit-log.csv");
});
+185 -1
View File
@@ -1,2 +1,186 @@
# SharepointToolbox-Web
# SharePoint Toolbox
A web admin toolbox for Microsoft 365 / SharePoint Online, built with Blazor Server (.NET 10) and Microsoft Graph.
## Features
- **Site management** — bulk site creation, folder-structure provisioning, templates
- **Members & permissions** — bulk member add, permission inspection
- **Content tools** — search, duplicate finder, file transfer, storage usage, version cleanup
- **Reporting** — on-demand reports, scheduled reports (unattended via app-only cert auth)
- **Auditing** — tenant-wide user-access audit (SP + M365/AAD group expansion)
- **Directory** — user directory browsing
- Multi-tenant via connection profiles. EN / FR localization.
## Requirements
- An Entra ID (Azure AD) app registration — see [Configuration](#configuration)
- Docker, **or** the .NET 10 SDK for bare-metal
## Configuration
Signing into the toolbox itself uses Microsoft OIDC (or a local account). Per-profile SharePoint/Graph access uses **certificate (app-only) auth** as the primary path — the same app identity drives both technician work and scheduled reports, so technicians never sign in per profile. Profiles without a certificate fall back to an interactive per-profile sign-in.
Set these as environment variables (or in `appsettings.json` under the `Oidc` section). .NET maps `Section__Key` to `Section:Key`.
| Variable | Description |
|----------|-------------|
| `Oidc__TenantId` | Entra tenant GUID |
| `Oidc__ClientId` | App registration client ID |
| `Oidc__ClientSecret` | App registration client secret |
| `App__Domain` | Public domain the app is reached at, e.g. `sptb.example.com` or `https://sptb.example.com` (scheme defaults to `https`). Pins the OIDC sign-in redirect (`/signin-oidc`) and derives the SharePoint-connect redirect URI. |
| `DataFolder` | Persistent data path (default `/data`) |
| `ASPNETCORE_ENVIRONMENT` | Must be `Production` to enable OIDC |
> In `Development`, OIDC is disabled — the app uses a cookie-only auto-login (hardcoded Admin) for local work.
### Two distinct OAuth flows — two redirect URIs
These are separate and registered on **different** Entra apps. Don't conflate them.
1. **App sign-in (OIDC).** Logging into the toolbox itself via "Sign in with Microsoft". Uses the `Oidc__*` app above. Callback path is the framework default `/signin-oidc` (not configurable here). When `App__Domain` is set, the redirect is pinned to `<domain>/signin-oidc`; otherwise it's derived from the request host (`X-Forwarded-Host`/`Host`).
→ On **this** app registration, add redirect URI `https://your-host/signin-oidc` under the **Web** platform. This app also needs the Graph permissions the audit/reporting features require: `GroupMember.Read.All`, `Group.Read.All`, `User.Read.All`.
2. **SharePoint connect (per-profile).** Getting a SharePoint/Graph token for a client tenant. The primary path is **certificate (app-only) auth** (see below), which needs no redirect URI. Profiles without a certificate fall back to an interactive PKCE public-client flow that uses **each connection profile's own `ClientId`/`TenantId`** — not the `Oidc__*` app. That fallback's callback is derived from `App__Domain` as `<domain>/connect/callback`; set `ClientConnect__RedirectUri` to override the full URL directly.
→ Only when using the interactive fallback: on **each client-tenant profile's** app registration, add that callback value (e.g. `https://your-host/connect/callback`) under the **Mobile and desktop / public client** platform. See [Per-profile app registration permissions](#per-profile-app-registration-permissions) below for the API permissions it needs.
### Per-profile app registration permissions
The **Register App** action in a profile provisions this registration for you (single tenant, public client) and grants org-wide admin consent automatically. The list below documents what it creates, for manual registration or auditing.
Per-profile access uses **certificate (app-only) auth** as the primary path: the app identity drives both interactive technician work and unattended features (scheduled reports, tenant-wide audits), so technicians never sign in per profile. The cert is provisioned alongside the registration and attached as a sign-in credential. The registration grants **application permissions** for that app-only auth; it also keeps **delegated scopes** for the interactive connect flow that profiles *without* a certificate fall back to.
**Application permissions** (app-only certificate auth — primary):
| API | Permission | Used for |
|-----|------------|----------|
| Microsoft Graph | `User.Read.All` | Look up users (unattended) |
| Microsoft Graph | `Group.ReadWrite.All` | Read group members; add members/owners (unattended) |
| Microsoft Graph | `Directory.Read.All` | Expand M365/AAD group membership in the user-access audit |
| Microsoft Graph | `Sites.FullControl.All` | Site operations (unattended) |
| Microsoft Graph | `Mail.Send` | Send emailed scheduled reports |
| SharePoint | `Sites.FullControl.All` | App-only CSOM |
**Delegated permissions** (interactive connect flow — only for profiles without a certificate):
| API | Permission | Used for |
|-----|------------|----------|
| Microsoft Graph | `User.Read` | Signed-in user's basic profile |
| Microsoft Graph | `User.Read.All` | Look up users by email/UPN |
| Microsoft Graph | `Group.ReadWrite.All` | Read group members; add members/owners |
| Microsoft Graph | `Sites.Read.All` | Resolve a site's `groupId` from its `siteId` |
| SharePoint | `AllSites.FullControl` | CSOM — site permissions, content, admin operations |
All permissions above require **admin consent**.
> **HTTPS note.** The sign-in app is a confidential (Web) client, so Entra requires its `/signin-oidc` redirect URI to be **HTTPS** — plain HTTP is allowed only for `http://localhost`, not a LAN host/IP. To run OIDC on a plain-HTTP LAN deployment, put the app behind an HTTPS-terminating reverse proxy: register `https://your-host/signin-oidc`, and the app honours `X-Forwarded-Proto` (see `UseForwardedHeaders`) to build the correct `https` redirect. Without a proxy, OIDC sign-in won't work over a non-localhost HTTP host — use the local email/password login instead.
> **Reverse-proxy host.** Set `App__Domain` so the app builds every redirect (cookie login, OIDC) against the public domain regardless of what host the proxy forwards. Without it, a proxy that doesn't forward the `Host` header makes the app 302 to the internal `IP:port` it actually received.
Persistent state (profiles, settings, templates, logs, exports, certs) lives in `DataFolder`.
## Installation — Docker (prebuilt image)
Pulls the published image from the Gitea registry — no local build needed.
```bash
cp .env.example .env # then edit .env with your OIDC values
docker compose -f docker-compose.prebuilt.yml pull
docker compose -f docker-compose.prebuilt.yml up -d
```
The compose file reads config from `.env` (see [`.env.example`](.env.example)). Pin a
version with `SPTB_TAG`, e.g. `SPTB_TAG=v1.2.0` in `.env`. Don't quote values — the
list form embeds literal quotes and breaks OIDC discovery.
## Installation — Docker (build locally)
```bash
docker compose up -d --build
```
App listens on **http://localhost:8080**. Data persists in the `sptb-data` volume.
Set your OIDC values in `docker-compose.yml` under `environment:`, or pass an env file:
```yaml
environment:
- ASPNETCORE_ENVIRONMENT=Production
- DataFolder=/data
- Oidc__TenantId=...
- Oidc__ClientId=...
- Oidc__ClientSecret=...
- ClientConnect__RedirectUri=https://your-host/connect/callback
```
Plain Docker (no compose):
```bash
docker build -t sptb-web .
docker run -d -p 8080:8080 \
-v sptb-data:/data \
-e ASPNETCORE_ENVIRONMENT=Production \
-e Oidc__TenantId=... \
-e Oidc__ClientId=... \
-e Oidc__ClientSecret=... \
-e ClientConnect__RedirectUri=https://your-host/connect/callback \
sptb-web
```
## Installation — Bare metal
Requires the [.NET 10 SDK](https://dotnet.microsoft.com/download).
```bash
# Restore + build
dotnet restore
dotnet publish -c Release -o ./publish
# Configure (PowerShell example)
$env:ASPNETCORE_ENVIRONMENT = "Production"
$env:DataFolder = "C:\sptb-data"
$env:Oidc__TenantId = "..."
$env:Oidc__ClientId = "..."
$env:Oidc__ClientSecret = "..."
$env:ClientConnect__RedirectUri = "https://your-host/connect/callback"
# Run
dotnet ./publish/SharepointToolbox.Web.dll
```
By default it listens on the Kestrel port (`http://localhost:5000`). Override with `ASPNETCORE_URLS`, e.g. `http://+:8080`.
### Local development
```bash
dotnet run
```
Runs in `Development` mode — OIDC off, auto-login as Admin. No Entra config needed.
## Tech stack
.NET 10 · Blazor Server · Microsoft Graph SDK · PnP.Framework · Serilog · CsvHelper
## Dependencies
NuGet packages and their pinned versions (see [`SharepointToolbox.Web.csproj`](SharepointToolbox.Web.csproj)). Last reviewed 2026-06-26.
| Package | Version | Purpose |
|---------|---------|---------|
| `Azure.Identity` | 1.21.0 | Entra ID token credentials — app-only certificate and client-secret auth |
| `CsvHelper` | 33.1.0 | CSV parsing for bulk import/export (sites, members, folder structures) |
| `Microsoft.AspNetCore.Authentication.OpenIdConnect` | 10.0.9 | OIDC "Sign in with Microsoft" app sign-in |
| `Microsoft.Graph` | 6.2.0 | Microsoft Graph SDK — users, groups, sites, mail |
| `Microsoft.Kiota.Abstractions` | 2.0.0 | Graph SDK request runtime (abstractions) |
| `Microsoft.Kiota.Authentication.Azure` | 2.0.0 | Graph SDK Azure auth provider |
| `Microsoft.Kiota.Http.HttpClientLibrary` | 2.0.0 | Graph SDK HTTP transport |
| `Microsoft.Kiota.Serialization.Form` | 2.0.0 | Graph SDK form serialization |
| `Microsoft.Kiota.Serialization.Json` | 2.0.0 | Graph SDK JSON serialization |
| `Microsoft.Kiota.Serialization.Multipart` | 2.0.0 | Graph SDK multipart serialization |
| `Microsoft.Kiota.Serialization.Text` | 2.0.0 | Graph SDK text serialization |
| `PnP.Framework` | 1.19.0 | SharePoint CSOM — site permissions, content, admin operations |
| `Serilog.AspNetCore` | 10.0.0 | Structured logging integration |
| `Serilog.Sinks.File` | 7.0.0 | Rolling-file log sink |
To check for newer releases: `dotnet list package --outdated`.
+93
View File
@@ -0,0 +1,93 @@
# Security findings
Review date: 2026-06-11. Items 14 and 6 fixed the same day; #5 reviewed and accepted by design.
Second pass (OWASP Top 10), 2026-06-11: items 711 below found and fixed the same day.
---
## 1. [HIGH] ✅ FIXED — Open redirect in `/connect/initiate` leaked SharePoint session tokens
**Was:** `returnUrl` from the query string was stored verbatim in the OAuth flow state and used as the final redirect target after the code exchange — with `token_key` (a 2-minute, redeemable handle to the connected client's refresh token) appended. `…?returnUrl=https://evil.com` leaked that handle off-domain, enabling connection hijack.
**Fix:** `returnUrl` is now constrained to a site-relative path via the new `string.ToLocalReturnUrl()` helper before it is stored ([Infrastructure/OAuth/OAuthEndpoints.cs](Infrastructure/OAuth/OAuthEndpoints.cs)). The helper rejects absolute *and* protocol-relative (`//host`, `/\host`) URLs.
---
## 2. [MEDIUM] ✅ FIXED — CSV formula injection in audit-log exports
**Was:** Both audit CSV exporters quoted per RFC 4180 but didn't neutralize a leading `=` `+` `-` `@`, so user-controlled fields (display/client/site names, details) could execute as formulas in Excel.
**Fix:** Both paths now use `CsvSanitizer.Escape` (which neutralizes formula prefixes): the `/audit/export` endpoint ([Program.cs](Program.cs)) and `AuditService.ExportCsvAsync` ([Services/Audit/AuditService.cs](Services/Audit/AuditService.cs)). The local `CsvEscape`/`Esc` duplicates were removed.
---
## 3. [LOW-MEDIUM] ✅ FIXED — Spoofable `X-Forwarded-Proto` could yield a non-Secure auth cookie
**Was:** Forwarded headers are trusted from any source (proxy IP unknown inside the container network), and the prod auth cookie used `SecurePolicy = SameAsRequest` — so a spoofed `X-Forwarded-Proto: http` or a direct plaintext hit could emit a non-Secure cookie.
**Fix:** Prod auth cookie now uses `CookieSecurePolicy.Always` ([Program.cs](Program.cs)), so the Secure flag is set regardless of the (untrusted) forwarded scheme. The forwarded-headers trust config is unchanged (still required behind the proxy); the residual XFF concern is limited — client IP is not used for auth and is not recorded in the audit log.
---
## 4. [LOW] ✅ FIXED — Open redirect in `/account/login/entra` and `/account/login/dev`
**Was:** `returnUrl` was used directly as the post-auth redirect on the entra and dev sign-in paths; only local-login validated it (and even that check allowed protocol-relative `//evil.com`).
**Fix:** All three sign-in redirects now route `returnUrl` through `ToLocalReturnUrl()` ([Program.cs](Program.cs)), which also closes the protocol-relative gap in the old local-login check.
---
## 5. [LOW] ⚪ ACCEPTED (by design) — Authenticated users can download any export / report
**Where:** `/export/download/{fileName}` and `/reports/download/{id}` ([Program.cs](Program.cs)).
**Decision:** Not changed. The app has no per-user profile authorization anywhere — any tech may select and use any shared profile (see the "standard techs use profiles without sign-in" design), and `GeneratedReport` carries only a `ProfileId`, no owner. Reports are listed and shared *per client/profile*, not per user, so per-user download scoping would contradict the product model rather than fix a boundary. Both endpoints already require authentication and strip path traversal (`Path.GetFileName`). Revisit only if a per-tech profile ACL is introduced.
---
## 6. [LOW] ✅ FIXED — Admin pages gated only by a render-time role check
**Was:** `/admin/users` and `/admin/audit` used `@attribute [Authorize]` (any authenticated user) plus an in-markup `if (Role != Admin) return;`.
**Fix:** Added an `Admin` authorization policy (`RequireClaim("app_role", "Admin")`) in [Program.cs](Program.cs) and both pages now use `[Authorize(Policy = "Admin")]` ([UserManagement.razor](Components/Pages/Admin/UserManagement.razor), [AuditLogs.razor](Components/Pages/Admin/AuditLogs.razor)). A claim-value policy (not `[Authorize(Roles=…)]`) was used deliberately: the local/dev sign-in identities don't set a `ClaimTypes.Role` claim, so a Roles check would have silently denied local admins. The in-component checks were kept as defense-in-depth.
---
## 7. [MEDIUM] ✅ FIXED — No brute-force protection on local sign-in (OWASP A07)
**Was:** `/account/local-login` and `UserService.ValidateLocalCredentialsAsync` had no rate limiting and no account lockout, leaving local accounts (incl. the bootstrap admin) open to unlimited password guessing — relevant given the plain-HTTP/LAN bootstrap deployment mode.
**Fix:** Two layers. (1) A per-account lockout on `AppUser` (`FailedLoginCount`/`LockoutEndUtc`) — 5 consecutive failures → 15-minute lock, counter cleared on success ([UserService.cs](Services/Auth/UserService.cs), [AppUser.cs](Core/Models/AppUser.cs)). A locked account is refused before the password is even checked, and failure stays generic (no enumeration). (2) An IP-partitioned fixed-window rate limiter (`"login"` policy, 10/min) on `/account/local-login` and `/account/login/entra` ([Program.cs](Program.cs)). The account lockout is the control that holds up when `X-Forwarded-For` is spoofed/rotated (forwarded headers are trusted from any source — see #3); the IP limiter is the coarse volumetric layer.
---
## 8. [MEDIUM] ✅ FIXED — Missing security response headers (OWASP A05)
**Was:** No `Content-Security-Policy`, `X-Frame-Options`, `X-Content-Type-Options`, or `Referrer-Policy`. A clickjacked page could drive the victim's live Blazor Server circuit, and there was no CSP backstop against injected script.
**Fix:** A middleware emits these on every response ([Program.cs](Program.cs)). CSP is tuned to the app: all scripts are external so `script-src 'self'` (no `unsafe-*`); `style-src` keeps `'unsafe-inline'` for the login page's inline `<style>` and the components' `style=""`; `img-src 'self' data:` for base64 client logos / download links; `connect-src 'self'` for the SignalR WebSocket; `frame-ancestors 'none'` (+ `X-Frame-Options: DENY`) blocks clickjacking.
---
## 9. [LOW-MEDIUM] ✅ FIXED — Container ran as root; `/data` world-readable (OWASP A05 / A02)
**Was:** The [Dockerfile](Dockerfile) had no `USER` directive, so the process ran as root and the `/data` volume (Data Protection keys, app-only certs, user store) had default permissions. The DP keys decrypt the auth cookie, the browser-stored SharePoint refresh tokens, and the on-disk certs — a large blast radius for anyone able to read the volume.
**Fix:** Final image now creates `/data` owned by the shipped non-root `app` user with mode `0700`, then `USER app` ([Dockerfile](Dockerfile)). Docker seeds a fresh named volume from that ownership/mode, so the running user can write it while other host users can't read the keys/certs at rest. (DP keys themselves remain unencrypted at rest — inherent to file-based keys in a container; the volume permissions are the mitigation.)
---
## 10. [LOW] ✅ FIXED — Raw token-endpoint response reflected into the redirect URL (OWASP A09)
**Was:** `/connect/callback` put the raw Azure token-endpoint JSON (and the provider's verbose `error_description`) into `?connect_error=…`, landing in browser history and — behind the proxy — proxy access logs. Those bodies can carry correlation/trace ids and claim hints.
**Fix:** The token-endpoint failure body and the verbose `error_description` are now logged server-side via `ILogger`; the redirect carries only a generic notice (or the short, safe OAuth `error` code) ([OAuthEndpoints.cs](Infrastructure/OAuth/OAuthEndpoints.cs)).
---
## 11. [LOW] ✅ FIXED — `/connect/callback` threw a 500 when no refresh_token was returned
**Was:** `root.GetProperty("refresh_token").GetString()!` threw `KeyNotFoundException` (→ 500 + stack trace in non-prod) when the tenant/app withheld `offline_access`.
**Fix:** Uses `TryGetProperty`; a missing/empty refresh token is logged and redirects with a friendly `connect_error` instead of throwing ([OAuthEndpoints.cs](Infrastructure/OAuth/OAuthEndpoints.cs)).
+11 -15
View File
@@ -2,6 +2,7 @@ using System.Text;
using Microsoft.AspNetCore.Authentication;
using SharepointToolbox.Web.Core.Models;
using SharepointToolbox.Web.Infrastructure.Persistence;
using SharepointToolbox.Web.Services.Export;
using SharepointToolbox.Web.Services.Session;
namespace SharepointToolbox.Web.Services.Audit;
@@ -41,23 +42,18 @@ public class AuditService : IAuditService
sb.AppendLine("Timestamp,UserEmail,UserDisplay,UserRole,Action,Client,Sites,Details");
foreach (var e in entries.OrderByDescending(x => x.Timestamp))
{
// CsvSanitizer adds spreadsheet formula-injection guards (= + - @) on top of
// RFC 4180 quoting; the user/display/client/site fields are user-controlled.
sb.AppendLine(string.Join(",",
CsvEscape(e.Timestamp.ToLocalTime().ToString("yyyy-MM-dd HH:mm:ss")),
CsvEscape(e.UserEmail),
CsvEscape(e.UserDisplay),
CsvEscape(e.UserRole.ToString()),
CsvEscape(e.Action),
CsvEscape(e.ClientName),
CsvEscape(string.Join("; ", e.Sites)),
CsvEscape(e.Details)));
CsvSanitizer.Escape(e.Timestamp.ToLocalTime().ToString("yyyy-MM-dd HH:mm:ss")),
CsvSanitizer.Escape(e.UserEmail),
CsvSanitizer.Escape(e.UserDisplay),
CsvSanitizer.Escape(e.UserRole.ToString()),
CsvSanitizer.Escape(e.Action),
CsvSanitizer.Escape(e.ClientName),
CsvSanitizer.Escape(string.Join("; ", e.Sites)),
CsvSanitizer.Escape(e.Details)));
}
return sb.ToString();
}
private static string CsvEscape(string value)
{
if (value.Contains(',') || value.Contains('"') || value.Contains('\n'))
return $"\"{value.Replace("\"", "\"\"")}\"";
return value;
}
}
+107 -49
View File
@@ -24,6 +24,22 @@ public class AppRegistrationService : IAppRegistrationService
"AllSites.FullControl", // CSOM — site permissions, content, admin operations
];
// Graph APPLICATION permissions (app roles) for certificate (app-only) auth.
private static readonly string[] GraphAppRoles =
[
"User.Read.All",
"Group.ReadWrite.All",
"Directory.Read.All", // expand M365/AAD group membership in the user-access audit (SharePointGroupResolver)
"Sites.FullControl.All",
"Mail.Send", // app-only sendMail for emailed scheduled reports
];
// SharePoint APPLICATION permission (app role) for certificate (app-only) CSOM.
private static readonly string[] SpAppRoles =
[
"Sites.FullControl.All",
];
private readonly HttpClient _http;
public AppRegistrationService(HttpClient http) { _http = http; }
@@ -32,103 +48,145 @@ public class AppRegistrationService : IAppRegistrationService
string adminAccessToken,
string tenantName,
string redirectUri,
CertProvisioningResult? appOnlyCert = null,
CancellationToken ct = default)
{
_http.DefaultRequestHeaders.Authorization =
new AuthenticationHeaderValue("Bearer", adminAccessToken);
// 1. Resolve Graph + SharePoint service principals in the target tenant
var (graphSpId, graphPermIds) = await ResolveServicePrincipalAsync(GraphAppId, GraphScopes, ct);
var (spSpId, spPermIds) = await ResolveServicePrincipalAsync(SharePointAppId, SpScopes, ct);
bool wantsAppOnly = appOnlyCert is not null;
// 2. Create app registration
var appBody = new
// 1. Resolve Graph + SharePoint service principals + the permission ids we need
var graph = await ResolveServicePrincipalAsync(GraphAppId, GraphScopes, GraphAppRoles, ct);
var sp = await ResolveServicePrincipalAsync(SharePointAppId, SpScopes, SpAppRoles, ct);
// 2. Create app registration (delegated scopes always; application roles when app-only)
var appBody = new Dictionary<string, object?>
{
displayName = $"SP Toolbox — {tenantName}",
signInAudience = "AzureADMyOrg",
isFallbackPublicClient = true,
["displayName"] = $"SP Toolbox — {tenantName}",
["signInAudience"] = "AzureADMyOrg",
["isFallbackPublicClient"] = true,
// Register the redirect under the PUBLIC client platform so the connect
// flow can redeem the auth code with PKCE only (no client secret). A
// redirect under `web` makes Entra treat the app as confidential and the
// token exchange fails with AADSTS7000218 (secret required).
publicClient = new { redirectUris = new[] { redirectUri } },
requiredResourceAccess = new[]
["publicClient"] = new { redirectUris = new[] { redirectUri } },
["requiredResourceAccess"] = new[]
{
new
{
resourceAppId = GraphAppId,
resourceAccess = graphPermIds.Select(id => new { id, type = "Scope" }).ToArray(),
resourceAccess = ResourceAccess(graph.ScopeIds, wantsAppOnly ? graph.AppRoleIds : []),
},
new
{
resourceAppId = SharePointAppId,
resourceAccess = spPermIds.Select(id => new { id, type = "Scope" }).ToArray(),
resourceAccess = ResourceAccess(sp.ScopeIds, wantsAppOnly ? sp.AppRoleIds : []),
},
},
};
var appJson = await PostGraphAsync("https://graph.microsoft.com/v1.0/applications",
appBody, ct);
// Attach the certificate as a sign-in credential so app-only token requests succeed.
if (wantsAppOnly)
appBody["keyCredentials"] = new[] { BuildKeyCredential(appOnlyCert!, tenantName) };
var appJson = await PostGraphAsync("https://graph.microsoft.com/v1.0/applications", appBody, ct);
var clientId = appJson.GetProperty("appId").GetString()!;
// 3. Create service principal for the new app
var spJson = await PostGraphAsync("https://graph.microsoft.com/v1.0/servicePrincipals",
var spJson = await PostGraphAsync("https://graph.microsoft.com/v1.0/servicePrincipals",
new { appId = clientId }, ct);
var newSpId = spJson.GetProperty("id").GetString()!;
// 4. Grant org-wide admin consent for Graph
await PostGraphAsync("https://graph.microsoft.com/v1.0/oauth2PermissionGrants",
new
{
clientId = newSpId,
consentType = "AllPrincipals",
resourceId = graphSpId,
scope = string.Join(" ", GraphScopes),
}, ct);
// 4. Grant org-wide admin consent for Graph + SharePoint delegated scopes
await GrantDelegatedConsentAsync(newSpId, graph.SpObjectId, GraphScopes, ct);
await GrantDelegatedConsentAsync(newSpId, sp.SpObjectId, SpScopes, ct);
// 5. Grant org-wide admin consent for SharePoint
await PostGraphAsync("https://graph.microsoft.com/v1.0/oauth2PermissionGrants",
new
{
clientId = newSpId,
consentType = "AllPrincipals",
resourceId = spSpId,
scope = string.Join(" ", SpScopes),
}, ct);
// 5. Grant admin consent for application permissions (app roles) when app-only
if (wantsAppOnly)
{
await GrantAppRolesAsync(newSpId, graph.SpObjectId, graph.AppRoleIds, ct);
await GrantAppRolesAsync(newSpId, sp.SpObjectId, sp.AppRoleIds, ct);
}
return clientId;
}
// Returns (servicePrincipalObjectId, [permissionIds matching requested scopes])
private async Task<(string SpObjectId, string[] PermissionIds)> ResolveServicePrincipalAsync(
string appId, string[] scopeNames, CancellationToken ct)
private static object BuildKeyCredential(CertProvisioningResult cert, string tenantName) => new
{
type = "AsymmetricX509Cert",
usage = "Verify",
key = cert.PublicCertBase64,
displayName = $"CN=SP Toolbox — {tenantName}",
startDateTime = cert.NotBefore.UtcDateTime.ToString("o"),
endDateTime = cert.NotAfter.UtcDateTime.ToString("o"),
};
private static object[] ResourceAccess(string[] scopeIds, string[] appRoleIds)
{
var list = new List<object>(scopeIds.Length + appRoleIds.Length);
list.AddRange(scopeIds.Select(id => new { id, type = "Scope" }));
list.AddRange(appRoleIds.Select(id => new { id, type = "Role" }));
return list.ToArray();
}
private async Task GrantDelegatedConsentAsync(string clientSpId, string resourceSpId, string[] scopes, CancellationToken ct)
{
await PostGraphAsync("https://graph.microsoft.com/v1.0/oauth2PermissionGrants",
new
{
clientId = clientSpId,
consentType = "AllPrincipals",
resourceId = resourceSpId,
scope = string.Join(" ", scopes),
}, ct);
}
private async Task GrantAppRolesAsync(string clientSpId, string resourceSpId, string[] appRoleIds, CancellationToken ct)
{
foreach (var appRoleId in appRoleIds)
{
await PostGraphAsync(
$"https://graph.microsoft.com/v1.0/servicePrincipals/{clientSpId}/appRoleAssignments",
new { principalId = clientSpId, resourceId = resourceSpId, appRoleId }, ct);
}
}
// Returns the SP object id plus the ids of the requested delegated scopes and application roles.
private async Task<(string SpObjectId, string[] ScopeIds, string[] AppRoleIds)> ResolveServicePrincipalAsync(
string appId, string[] scopeNames, string[] roleNames, CancellationToken ct)
{
var url = $"https://graph.microsoft.com/v1.0/servicePrincipals" +
$"?$filter=appId eq '{appId}'&$select=id,oauth2PermissionScopes";
$"?$filter=appId eq '{appId}'&$select=id,oauth2PermissionScopes,appRoles";
var resp = await _http.GetAsync(url, ct);
var json = await resp.Content.ReadAsStringAsync(ct);
resp.EnsureSuccessStatusCode();
using var doc = JsonDocument.Parse(json);
var values = doc.RootElement.GetProperty("value");
var sp = values.EnumerateArray().First();
var spId = sp.GetProperty("id").GetString()!;
var allScopes = sp.GetProperty("oauth2PermissionScopes");
using var doc = JsonDocument.Parse(json);
var sp = doc.RootElement.GetProperty("value").EnumerateArray().First();
var spId = sp.GetProperty("id").GetString()!;
var scopeIds = MatchByValue(sp.GetProperty("oauth2PermissionScopes"), scopeNames);
var roleIds = MatchByValue(sp.GetProperty("appRoles"), roleNames);
return (spId, scopeIds, roleIds);
}
private static string[] MatchByValue(JsonElement entries, string[] wantedValues)
{
var ids = new List<string>();
foreach (var scope in allScopes.EnumerateArray())
foreach (var entry in entries.EnumerateArray())
{
var value = scope.GetProperty("value").GetString();
if (scopeNames.Contains(value, StringComparer.OrdinalIgnoreCase))
ids.Add(scope.GetProperty("id").GetString()!);
var value = entry.GetProperty("value").GetString();
if (wantedValues.Contains(value, StringComparer.OrdinalIgnoreCase))
ids.Add(entry.GetProperty("id").GetString()!);
}
return (spId, ids.ToArray());
return ids.ToArray();
}
private async Task<JsonElement> PostGraphAsync(string url, object body, CancellationToken ct)
{
var content = new StringContent(
var content = new StringContent(
JsonSerializer.Serialize(body, new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase }),
Encoding.UTF8,
"application/json");
+57
View File
@@ -0,0 +1,57 @@
using System.Security.Cryptography;
using System.Security.Cryptography.X509Certificates;
using SharepointToolbox.Web.Infrastructure.Auth;
namespace SharepointToolbox.Web.Services.Auth;
/// <summary>
/// Creates a 2048-bit RSA self-signed certificate valid for two years, persists its private
/// key (PFX) through <see cref="IAppOnlyCertStore"/>, and returns the public certificate so
/// the caller can attach it to the Entra app registration as a sign-in credential.
/// </summary>
public class CertProvisioningService : ICertProvisioningService
{
private readonly IAppOnlyCertStore _certStore;
public CertProvisioningService(IAppOnlyCertStore certStore) { _certStore = certStore; }
public async Task<CertProvisioningResult> GenerateAndStoreAsync(
string profileId, string subjectName, CancellationToken ct = default)
{
// X.509 validity is stored at whole-second precision (ASN.1 has no sub-second field).
// Truncate here so the keyCredential start/endDateTime we send to Graph match the
// certificate's embedded validity exactly — otherwise the JSON endDateTime carries
// a fractional second that lands *after* the cert's NotAfter and Graph rejects it
// with KeyCredentialsInvalidEndDate.
var notBefore = TruncateToSecond(DateTimeOffset.UtcNow.AddMinutes(-5));
var notAfter = notBefore.AddYears(2);
using var rsa = RSA.Create(2048);
var req = new CertificateRequest(
$"CN={Sanitize(subjectName)}", rsa, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
req.CertificateExtensions.Add(new X509BasicConstraintsExtension(false, false, 0, false));
req.CertificateExtensions.Add(new X509KeyUsageExtension(X509KeyUsageFlags.DigitalSignature, false));
using var cert = req.CreateSelfSigned(notBefore, notAfter);
// Transient password only protects the in-memory PFX handoff to the store, which
// re-exports it password-less and encrypts at rest with Data Protection.
var transientPwd = Convert.ToBase64String(RandomNumberGenerator.GetBytes(24));
var pfxBytes = cert.Export(X509ContentType.Pkcs12, transientPwd);
var thumbprint = await _certStore.SaveAsync(profileId, pfxBytes, transientPwd, ct);
var publicBase64 = Convert.ToBase64String(cert.Export(X509ContentType.Cert));
return new CertProvisioningResult(thumbprint, publicBase64, notBefore, notAfter);
}
private static DateTimeOffset TruncateToSecond(DateTimeOffset value) =>
new(value.Ticks - (value.Ticks % TimeSpan.TicksPerSecond), value.Offset);
// CN cannot contain characters that break the X.500 distinguished name.
private static string Sanitize(string name)
{
var cleaned = name.Replace(",", " ").Replace("=", " ").Replace("\"", " ").Trim();
return string.IsNullOrEmpty(cleaned) ? "SP Toolbox" : cleaned;
}
}
+10 -3
View File
@@ -4,13 +4,20 @@ public interface IAppRegistrationService
{
/// <summary>
/// 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 <paramref name="appOnlyCert"/> is supplied, the registration is also provisioned for
/// certificate (app-only) auth: the public certificate is attached as a sign-in credential,
/// SharePoint + Graph <em>application</em> permissions are requested, and admin consent for
/// those app roles is granted. This lets technicians operate under the app identity without an
/// interactive sign-in. Returns the new app's client ID (appId).
/// </summary>
Task<string> CreateAsync(
string adminAccessToken,
string tenantName,
string redirectUri,
CertProvisioningResult? appOnlyCert = null,
CancellationToken ct = default);
}
+25
View File
@@ -0,0 +1,25 @@
namespace SharepointToolbox.Web.Services.Auth;
/// <summary>
/// Public material of a freshly generated app-only certificate. The private key is already
/// stored (encrypted) in the cert store; these fields are what the app registration needs
/// to trust the certificate as a sign-in credential.
/// </summary>
/// <param name="Thumbprint">SHA-1 thumbprint of the generated certificate.</param>
/// <param name="PublicCertBase64">Base64 of the DER-encoded public certificate (Graph keyCredential.key).</param>
/// <param name="NotBefore">Validity start (UTC).</param>
/// <param name="NotAfter">Validity end (UTC).</param>
public record CertProvisioningResult(
string Thumbprint,
string PublicCertBase64,
DateTimeOffset NotBefore,
DateTimeOffset NotAfter);
/// <summary>
/// Generates a self-signed certificate for a client profile, stores the private key in the
/// app-only cert store, and returns the public material to register against the Entra app.
/// </summary>
public interface ICertProvisioningService
{
Task<CertProvisioningResult> GenerateAndStoreAsync(string profileId, string subjectName, CancellationToken ct = default);
}
+3 -1
View File
@@ -11,7 +11,9 @@ public interface IUserService
Task<AppUser?> GetByEmailAsync(string email);
Task<IReadOnlyList<AppUser>> GetAllAsync();
Task UpdateRoleAsync(string userId, UserRole role);
/// <summary>Persist a new role for the user. Returns the previous role (read from the store).</summary>
/// <exception cref="KeyNotFoundException">No user matches <paramref name="userId"/>.</exception>
Task<UserRole> UpdateRoleAsync(string userId, UserRole role);
Task DeleteAsync(string userId);
/// <summary>Create a local password-based account. First user ever becomes Admin.</summary>
+54 -4
View File
@@ -1,5 +1,6 @@
using System.Security.Claims;
using Microsoft.AspNetCore.Identity;
using Microsoft.Extensions.Logging;
using SharepointToolbox.Web.Core.Models;
using SharepointToolbox.Web.Infrastructure.Persistence;
@@ -9,11 +10,13 @@ public class UserService : IUserService
{
private readonly UserRepository _repo;
private readonly IPasswordHasher<AppUser> _hasher;
private readonly ILogger<UserService> _logger;
public UserService(UserRepository repo, IPasswordHasher<AppUser> hasher)
public UserService(UserRepository repo, IPasswordHasher<AppUser> hasher, ILogger<UserService> logger)
{
_repo = repo;
_hasher = hasher;
_logger = logger;
}
public async Task<AppUser> ProvisionAsync(ClaimsPrincipal principal)
@@ -56,13 +59,23 @@ public class UserService : IUserService
public Task<IReadOnlyList<AppUser>> GetAllAsync() => _repo.LoadAsync();
public async Task UpdateRoleAsync(string userId, UserRole role)
public async Task<UserRole> UpdateRoleAsync(string userId, UserRole role)
{
var users = (await _repo.LoadAsync()).ToList();
var user = users.FirstOrDefault(u => u.Id == userId)
?? throw new KeyNotFoundException($"User {userId} not found.");
?? throw new KeyNotFoundException($"User '{userId}' not found among {users.Count} stored users.");
var oldRole = user.Role;
user.Role = role;
await _repo.UpsertAsync(user);
// Verify the write landed by re-reading the row from disk.
var persisted = (await _repo.LoadAsync()).FirstOrDefault(u => u.Id == user.Id)?.Role;
_logger.LogInformation(
"UpdateRoleAsync: {Email} (id {Id}) {OldRole} → {NewRole}; persisted value now {Persisted}.",
user.Email, user.Id, oldRole, role, persisted);
return oldRole;
}
public Task DeleteAsync(string userId) => _repo.DeleteAsync(userId);
@@ -96,21 +109,58 @@ public class UserService : IUserService
return user;
}
// Brute-force lockout: after this many consecutive failures the account is locked
// for the window below. Tuned to stop guessing while staying usable for a fat-fingered
// admin. The counter resets on any successful sign-in.
private const int LockoutThreshold = 5;
private static readonly TimeSpan LockoutWindow = TimeSpan.FromMinutes(15);
public async Task<AppUser?> ValidateLocalCredentialsAsync(string email, string password)
{
var user = await _repo.FindByEmailAsync(email);
if (user is null || user.Provider != AuthProvider.Local || string.IsNullOrEmpty(user.PasswordHash))
return null;
var now = DateTimeOffset.UtcNow;
// Account currently locked → refuse without even checking the password, so a locked
// account can't be probed. Returning null (generic failure) avoids account enumeration.
if (user.LockoutEndUtc is { } until && until > now)
{
_logger.LogWarning("Local login blocked: account {Email} is locked until {Until:o}.", user.Email, until);
return null;
}
// Lock window elapsed → clear it before re-evaluating.
if (user.LockoutEndUtc is not null)
{
user.LockoutEndUtc = null;
user.FailedLoginCount = 0;
}
var result = _hasher.VerifyHashedPassword(user, user.PasswordHash, password);
if (result == PasswordVerificationResult.Failed)
{
user.FailedLoginCount++;
if (user.FailedLoginCount >= LockoutThreshold)
{
user.LockoutEndUtc = now + LockoutWindow;
_logger.LogWarning(
"Local login: account {Email} locked for {Minutes} min after {Count} failed attempts.",
user.Email, LockoutWindow.TotalMinutes, user.FailedLoginCount);
}
await _repo.UpsertAsync(user);
return null;
}
// Transparently upgrade the hash if the algorithm parameters changed
if (result == PasswordVerificationResult.SuccessRehashNeeded)
user.PasswordHash = _hasher.HashPassword(user, password);
user.LastLogin = DateTimeOffset.UtcNow;
// Success → clear the failure trail.
user.FailedLoginCount = 0;
user.LockoutEndUtc = null;
user.LastLogin = now;
await _repo.UpsertAsync(user);
return user;
}
+1 -1
View File
@@ -4,7 +4,7 @@ namespace SharepointToolbox.Web.Services;
public interface IUserAccessAuditService
{
Task<IReadOnlyList<UserAccessEntry>> AuditUsersAsync(
Task<UserAccessAuditResult> AuditUsersAsync(
ISessionManager sessionManager,
TenantProfile currentProfile,
IReadOnlyList<string> targetUserLogins,
+21
View File
@@ -0,0 +1,21 @@
using SharepointToolbox.Web.Core.Models;
namespace SharepointToolbox.Web.Services.Reports;
/// <summary>Sends a generated report as a Graph email (app-only, Mail.Send).</summary>
public interface IReportMailService
{
/// <summary>
/// Sends <paramref name="bytes"/> as an attachment to the recipients in
/// <paramref name="settings"/>, sending AS <see cref="ReportEmailSettings.From"/>.
/// Subject/body placeholders are resolved from the schedule and client.
/// </summary>
Task SendAsync(
TenantProfile profile,
ScheduledReport schedule,
ReportEmailSettings settings,
string fileName,
string mime,
byte[] bytes,
CancellationToken ct = default);
}
@@ -0,0 +1,14 @@
using SharepointToolbox.Web.Core.Models;
namespace SharepointToolbox.Web.Services.Reports;
public interface IScheduledReportRunner
{
/// <summary>
/// Generates one report for the given schedule using app-only auth, writes the
/// file under the client's exports subfolder, records it in the report index, and
/// audit-logs the run. Never throws for report-level failures — a failed run is
/// captured as a <see cref="GeneratedReport"/> with <see cref="ReportRunStatus.Failed"/>.
/// </summary>
Task<GeneratedReport> RunAsync(ScheduledReport schedule, CancellationToken ct = default);
}
+90
View File
@@ -0,0 +1,90 @@
using Microsoft.Graph.Models;
using Microsoft.Graph.Users.Item.SendMail;
using SharepointToolbox.Web.Core.Models;
using SharepointToolbox.Web.Infrastructure.Auth;
namespace SharepointToolbox.Web.Services.Reports;
/// <summary>
/// Delivers a generated report by email through Graph. Uses the client's app-only
/// (certificate) Graph client, which has no signed-in user, so it posts to
/// <c>/users/{From}/sendMail</c> — the configured sender mailbox must exist in the
/// tenant and the app registration must hold the <c>Mail.Send</c> application role.
/// </summary>
public class ReportMailService : IReportMailService
{
// Graph caps a single sendMail request at ~4 MB total; larger files need an upload
// session we don't implement. Reject early with a clear message instead of a 413.
private const long MaxAttachmentBytes = 3 * 1024 * 1024;
private readonly IAppOnlyContextFactory _appOnly;
public ReportMailService(IAppOnlyContextFactory appOnly) { _appOnly = appOnly; }
public async Task SendAsync(
TenantProfile profile,
ScheduledReport schedule,
ReportEmailSettings settings,
string fileName,
string mime,
byte[] bytes,
CancellationToken ct = default)
{
if (string.IsNullOrWhiteSpace(settings.From))
throw new InvalidOperationException("No sender mailbox (From) configured for report email.");
var to = CleanAddresses(settings.To);
var cc = CleanAddresses(settings.Cc);
if (to.Count == 0 && cc.Count == 0)
throw new InvalidOperationException("Report email has no To or Cc recipients.");
var message = new Message
{
Subject = Substitute(settings.Subject, profile, schedule, fileName),
Body = new ItemBody
{
ContentType = BodyType.Html,
Content = Substitute(settings.Body, profile, schedule, fileName)
},
ToRecipients = to.Select(Recipient).ToList(),
CcRecipients = cc.Select(Recipient).ToList(),
};
if (bytes.LongLength > MaxAttachmentBytes)
throw new InvalidOperationException(
$"Report '{fileName}' is {bytes.LongLength / 1024.0 / 1024.0:F1} MB — too large to email " +
$"(Graph sendMail limit ~{MaxAttachmentBytes / 1024 / 1024} MB).");
message.Attachments = new List<Attachment>
{
new FileAttachment
{
OdataType = "#microsoft.graph.fileAttachment",
Name = fileName,
ContentType = mime,
ContentBytes = bytes,
}
};
var graph = await _appOnly.CreateGraphClientAsync(profile, ct);
await graph.Users[settings.From].SendMail.PostAsync(
new SendMailPostRequestBody { Message = message, SaveToSentItems = false }, cancellationToken: ct);
}
private static List<string> CleanAddresses(IEnumerable<string> raw) =>
raw.Select(a => a?.Trim() ?? string.Empty)
.Where(a => a.Length > 0)
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToList();
private static Recipient Recipient(string address) =>
new() { EmailAddress = new EmailAddress { Address = address } };
private static string Substitute(string template, TenantProfile profile, ScheduledReport schedule, string fileName) =>
(template ?? string.Empty)
.Replace("{ReportName}", schedule.Name)
.Replace("{ClientName}", profile.Name)
.Replace("{ReportType}", schedule.Type.ToString())
.Replace("{FileName}", fileName)
.Replace("{DateUtc}", DateTime.UtcNow.ToString("yyyy-MM-dd HH:mm"));
}
@@ -0,0 +1,130 @@
using Microsoft.Extensions.Logging;
using SharepointToolbox.Web.Core.Models;
using SharepointToolbox.Web.Infrastructure.Persistence;
namespace SharepointToolbox.Web.Services.Reports;
/// <summary>
/// Background scheduler. Every <see cref="TickInterval"/> it loads the schedule
/// definitions, runs any whose <see cref="ScheduledReport.NextRunUtc"/> is due, and
/// advances their next-run stamp. Each run executes in its own DI scope (report
/// services are scoped). Due schedules run sequentially within a tick to bound the
/// load a single tenant sees; a long run simply delays the next due check.
/// </summary>
public class ScheduledReportHostedService : BackgroundService
{
private static readonly TimeSpan TickInterval = TimeSpan.FromMinutes(1);
private readonly IServiceScopeFactory _scopeFactory;
private readonly ScheduledReportRepository _repo;
private readonly ScheduledRunCoordinator _coordinator;
private readonly ILogger<ScheduledReportHostedService> _log;
public ScheduledReportHostedService(
IServiceScopeFactory scopeFactory,
ScheduledReportRepository repo,
ScheduledRunCoordinator coordinator,
ILogger<ScheduledReportHostedService> log)
{
_scopeFactory = scopeFactory;
_repo = repo;
_coordinator = coordinator;
_log = log;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
_log.LogInformation("Scheduled report service started (tick {Interval}).", TickInterval);
using var timer = new PeriodicTimer(TickInterval);
// Tick once at startup, then on every interval.
do
{
try { await TickAsync(stoppingToken); }
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) { break; }
catch (Exception ex) { _log.LogError(ex, "Scheduled report tick failed."); }
}
while (await WaitAsync(timer, stoppingToken));
_log.LogInformation("Scheduled report service stopping.");
}
private static async Task<bool> WaitAsync(PeriodicTimer timer, CancellationToken ct)
{
try { return await timer.WaitForNextTickAsync(ct); }
catch (OperationCanceledException) { return false; }
}
private async Task TickAsync(CancellationToken ct)
{
// Global pause: an admin has suspended all cadence-triggered runs. In-flight
// runs are unaffected (those are stopped individually); due schedules simply
// wait — NextRun is not advanced, so they fire once resumed.
if (_coordinator.IsPaused) return;
var now = DateTime.UtcNow;
var schedules = await _repo.LoadAsync();
foreach (var schedule in schedules)
{
ct.ThrowIfCancellationRequested();
if (!schedule.Enabled) continue;
// First time we see an enabled schedule with no next-run: arm it, don't run.
if (schedule.NextRunUtc is null)
{
schedule.NextRunUtc = schedule.Recurrence.ComputeNextRunUtc(now);
await _repo.UpsertAsync(schedule);
continue;
}
if (schedule.NextRunUtc > now) continue;
await RunOneAsync(schedule, now, ct);
}
}
private async Task RunOneAsync(ScheduledReport schedule, DateTime now, CancellationToken ct)
{
// Register the run so the UI can stop it mid-flight. The returned token trips on
// either app shutdown (ct) or an admin Stop. Null = a run is already in progress
// (e.g. a long previous run or a "Run now"); skip without advancing so it retries.
var token = _coordinator.TryBegin(schedule.Id, ct);
if (token is null)
{
_log.LogWarning("Schedule '{Name}' ({Id}) still running; skipping this tick.", schedule.Name, schedule.Id);
return;
}
try
{
using var scope = _scopeFactory.CreateScope();
var runner = scope.ServiceProvider.GetRequiredService<IScheduledReportRunner>();
await runner.RunAsync(schedule, token.Value);
}
catch (OperationCanceledException) when (ct.IsCancellationRequested)
{
throw; // app shutdown — bubble up to stop the service loop
}
catch (OperationCanceledException)
{
// Stopped via the coordinator (admin Stop / Stop all), not shutdown. Not a failure.
_log.LogInformation("Schedule '{Name}' ({Id}) was stopped.", schedule.Name, schedule.Id);
}
catch (Exception ex)
{
// RunAsync already captures report-level failures; this guards anything
// thrown outside it so one bad schedule can't stop the others advancing.
_log.LogError(ex, "Schedule '{Name}' ({Id}) failed to run.", schedule.Name, schedule.Id);
}
finally
{
_coordinator.Complete(schedule.Id);
// Advance regardless of outcome so a persistently failing (or stopped)
// schedule doesn't hot-loop every tick.
schedule.LastRunUtc = now;
schedule.NextRunUtc = schedule.Recurrence.ComputeNextRunUtc(now);
await _repo.UpsertAsync(schedule);
}
}
}
+297
View File
@@ -0,0 +1,297 @@
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Microsoft.SharePoint.Client;
using SharepointToolbox.Web.Core.Helpers;
using SharepointToolbox.Web.Core.Models;
using SharepointToolbox.Web.Infrastructure.Auth;
using SharepointToolbox.Web.Infrastructure.Persistence;
using SharepointToolbox.Web.Services.Export;
using AppConfiguration = SharepointToolbox.Web.Core.Models.AppConfiguration;
namespace SharepointToolbox.Web.Services.Reports;
/// <summary>
/// Drives one scheduled report end-to-end under app-only auth: resolve the client
/// profile, discover/select sites, scan each site with the matching report service,
/// merge the per-site results into a single artifact, write it to the client's
/// exports subfolder, and index + audit the run.
///
/// Report services take a plain <see cref="ClientContext"/>, so they are reused
/// verbatim; the only difference from the interactive pages is that the context is
/// produced by <see cref="IAppOnlyContextFactory"/> instead of the delegated session.
/// </summary>
public class ScheduledReportRunner : IScheduledReportRunner
{
private readonly ProfileRepository _profiles;
private readonly SettingsRepository _settings;
private readonly GeneratedReportRepository _index;
private readonly AuditRepository _audit;
private readonly IAppOnlyContextFactory _appOnly;
private readonly IReportMailService _mail;
private readonly AppConfiguration _cfg;
private readonly ILogger<ScheduledReportRunner> _log;
private readonly IPermissionsService _perm;
private readonly ISharePointGroupResolver _groupResolver;
private readonly IStorageService _storage;
private readonly IDuplicatesService _dup;
private readonly ISearchService _search;
private readonly IVersionCleanupService _version;
private readonly CsvExportService _permCsv;
private readonly HtmlExportService _permHtml;
private readonly StorageCsvExportService _storageCsv;
private readonly StorageHtmlExportService _storageHtml;
private readonly DuplicatesCsvExportService _dupCsv;
private readonly DuplicatesHtmlExportService _dupHtml;
private readonly SearchCsvExportService _searchCsv;
private readonly SearchHtmlExportService _searchHtml;
private readonly UserAccessCsvExportService _uaCsv;
private readonly UserAccessHtmlExportService _uaHtml;
private readonly VersionCleanupHtmlExportService _versionHtml;
public ScheduledReportRunner(
ProfileRepository profiles, SettingsRepository settings,
GeneratedReportRepository index, AuditRepository audit,
IAppOnlyContextFactory appOnly, IReportMailService mail, IOptions<AppConfiguration> cfg,
ILogger<ScheduledReportRunner> log,
IPermissionsService perm, ISharePointGroupResolver groupResolver,
IStorageService storage, IDuplicatesService dup,
ISearchService search, IVersionCleanupService version,
CsvExportService permCsv, HtmlExportService permHtml,
StorageCsvExportService storageCsv, StorageHtmlExportService storageHtml,
DuplicatesCsvExportService dupCsv, DuplicatesHtmlExportService dupHtml,
SearchCsvExportService searchCsv, SearchHtmlExportService searchHtml,
UserAccessCsvExportService uaCsv, UserAccessHtmlExportService uaHtml,
VersionCleanupHtmlExportService versionHtml)
{
_profiles = profiles; _settings = settings; _index = index; _audit = audit;
_appOnly = appOnly; _mail = mail; _cfg = cfg.Value; _log = log;
_perm = perm; _groupResolver = groupResolver; _storage = storage; _dup = dup; _search = search; _version = version;
_permCsv = permCsv; _permHtml = permHtml;
_storageCsv = storageCsv; _storageHtml = storageHtml;
_dupCsv = dupCsv; _dupHtml = dupHtml;
_searchCsv = searchCsv; _searchHtml = searchHtml;
_uaCsv = uaCsv; _uaHtml = uaHtml; _versionHtml = versionHtml;
}
public async Task<GeneratedReport> RunAsync(ScheduledReport schedule, CancellationToken ct = default)
{
var profile = (await _profiles.LoadAsync()).FirstOrDefault(p => p.Id == schedule.ProfileId);
if (profile is null)
return await FailAsync(schedule, profileName: "(unknown)", $"Client profile '{schedule.ProfileId}' not found.");
if (!profile.AppOnlyEnabled)
return await FailAsync(schedule, profile.Name, $"App-only reports are not enabled for client '{profile.Name}'.");
try
{
var sites = await ResolveSitesAsync(profile, schedule, ct);
if (sites.Count == 0)
return await FailAsync(schedule, profile.Name, "No sites resolved for this schedule.");
var settings = await _settings.LoadAsync();
var branding = new ReportBranding(settings.MspLogo, profile.ClientLogo);
var ts = DateTime.UtcNow.ToString("yyyyMMdd_HHmmss");
var output = await GenerateAsync(profile, schedule, sites, branding, ts, ct);
var dir = Path.Combine(_cfg.ExportsFolder, profile.Id);
Directory.CreateDirectory(dir);
var path = Path.Combine(dir, output.FileName);
await System.IO.File.WriteAllBytesAsync(path, output.Bytes, ct);
var report = new GeneratedReport
{
ProfileId = profile.Id,
ScheduledReportId = schedule.Id,
Type = schedule.Type,
Name = schedule.Name,
FileName = output.FileName,
Mime = output.Mime,
SizeBytes = output.Bytes.LongLength,
Status = ReportRunStatus.Success
};
// Optional email delivery. A delivery failure does NOT fail the report —
// the file is already on disk and indexed; we record the error on the entry.
string mailNote = "";
if (schedule.Email.Enabled)
{
try
{
await _mail.SendAsync(profile, schedule, schedule.Email,
output.FileName, output.Mime, output.Bytes, ct);
report.Emailed = true;
mailNote = $", emailed to {schedule.Email.To.Count + schedule.Email.Cc.Count} recipient(s)";
}
catch (OperationCanceledException) { throw; }
catch (Exception mex)
{
report.EmailError = mex.Message;
mailNote = $", email FAILED: {mex.Message}";
_log.LogError(mex, "Emailing report '{Name}' for '{Client}' failed", schedule.Name, profile.Name);
}
}
await _index.AddAsync(report);
await AuditAsync(profile.Name, schedule, ReportRunStatus.Success,
$"{schedule.Type} report '{output.FileName}' ({sites.Count} site(s), {output.Bytes.LongLength / 1024.0:F1} KB){mailNote}");
_log.LogInformation("Scheduled report '{Name}' for '{Client}' produced {File}",
schedule.Name, profile.Name, output.FileName);
return report;
}
catch (OperationCanceledException)
{
throw;
}
catch (Exception ex)
{
_log.LogError(ex, "Scheduled report '{Name}' for '{Client}' failed", schedule.Name, profile.Name);
return await FailAsync(schedule, profile.Name, ex.Message);
}
}
private async Task<IReadOnlyList<SiteInfo>> ResolveSitesAsync(
TenantProfile profile, ScheduledReport schedule, CancellationToken ct)
{
if (!schedule.AllSites)
return schedule.SiteUrls
.Where(u => !string.IsNullOrWhiteSpace(u))
.Select(u => new SiteInfo(u, ReportSplitHelper.DeriveSiteLabel(u)))
.ToList();
using var adminCtx = await _appOnly.CreateContextAsync(
profile, TenantSiteEnumerator.BuildAdminUrl(profile.TenantUrl), ct);
return await TenantSiteEnumerator.EnumerateAsync(adminCtx, ct);
}
private async Task<MergeOutput> GenerateAsync(
TenantProfile profile, ScheduledReport schedule, IReadOnlyList<SiteInfo> sites,
ReportBranding branding, string ts, CancellationToken ct)
{
var o = schedule.Options;
var progress = new Progress<OperationProgress>();
var mode = schedule.MergeMode;
var fmt = schedule.Format;
// Runs the same per-site scan loop for every report type. ClientContext is
// app-only and disposed per site.
async Task<List<(string Label, IReadOnlyList<T> Results)>> ScanSites<T>(
Func<ClientContext, SiteInfo, Task<IReadOnlyList<T>>> scan)
{
var bySite = new List<(string, IReadOnlyList<T>)>();
foreach (var site in sites)
{
ct.ThrowIfCancellationRequested();
using var ctx = await _appOnly.CreateContextAsync(profile, site.Url, ct);
bySite.Add((site.Title, await scan(ctx, site)));
}
return bySite;
}
switch (schedule.Type)
{
case ReportType.Permissions:
{
var opts = new ScanOptions(o.IncludeInherited, o.ScanFolders, o.FolderDepth, o.IncludeSubsites);
var bySite = await ScanSites<PermissionEntry>((ctx, _) => _perm.ScanSiteAsync(ctx, opts, progress, ct));
return ReportMergeHelper.Build(bySite, mode, "permissions", ts, fmt,
fmt == ReportFormat.Csv ? rs => _permCsv.BuildCsv(rs) : rs => _permHtml.BuildHtml(rs, branding));
}
case ReportType.Storage:
{
var opts = new StorageScanOptions(o.PerLibrary, o.IncludeSubsites, o.FolderDepth,
o.IncludeHiddenLibraries, o.IncludePreservationHold, o.IncludeListAttachments, o.IncludeRecycleBin);
var bySite = await ScanSites<StorageNode>((ctx, _) => _storage.CollectStorageAsync(ctx, opts, progress, ct));
return ReportMergeHelper.Build(bySite, mode, "storage", ts, fmt,
fmt == ReportFormat.Csv ? rs => _storageCsv.BuildCsv(rs) : rs => _storageHtml.BuildHtml(rs, branding));
}
case ReportType.Duplicates:
{
var opts = new DuplicateScanOptions(o.DuplicateMode, o.MatchSize, o.MatchCreated, o.MatchModified,
o.MatchSubfolderCount, o.MatchFileCount, o.IncludeSubsites, o.Library);
var bySite = await ScanSites<DuplicateGroup>((ctx, _) => _dup.ScanDuplicatesAsync(ctx, opts, progress, ct));
return ReportMergeHelper.Build(bySite, mode, "duplicates", ts, fmt,
fmt == ReportFormat.Csv ? rs => _dupCsv.BuildCsv(rs) : rs => _dupHtml.BuildHtml(rs, branding));
}
case ReportType.Search:
{
var bySite = await ScanSites<SearchResult>((ctx, site) =>
{
var opts = new SearchOptions(o.Extensions.ToArray(), o.Regex,
null, null, null, null, null, null, o.Library, o.MaxResults, site.Url);
return _search.SearchFilesAsync(ctx, opts, progress, ct);
});
return ReportMergeHelper.Build(bySite, mode, "search", ts, fmt,
fmt == ReportFormat.Csv ? rs => _searchCsv.BuildCsv(rs) : rs => _searchHtml.BuildHtml(rs, branding));
}
case ReportType.UserAccess:
{
var opts = new ScanOptions(o.IncludeInherited, o.ScanFolders, o.FolderDepth, o.IncludeSubsites);
var targets = o.TargetUserLogins
.Select(l => l.Trim().ToLowerInvariant())
.Where(l => l.Length > 0).ToHashSet();
var bySite = await ScanSites<UserAccessEntry>(async (ctx, site) =>
{
var permEntries = await _perm.ScanSiteAsync(ctx, opts, progress, ct);
// Expand SharePoint group membership so group-granted access is attributed to
// the target user (otherwise the scan only sees the group principal, not the user).
var groupMembers = await UserAccessAuditService.ResolveGroupMembersAsync(
_groupResolver, ctx, profile, permEntries, ct);
return UserAccessAuditService.TransformEntries(permEntries, targets, site, groupMembers).ToList();
});
return ReportMergeHelper.Build(bySite, mode, "user_audit", ts, fmt,
fmt == ReportFormat.Csv
? rs => _uaCsv.BuildCsv(rs.FirstOrDefault()?.UserDisplayName ?? "Users", rs.FirstOrDefault()?.UserLogin ?? "", rs)
: rs => _uaHtml.BuildHtml(rs, mergePermissions: false, branding: branding));
}
case ReportType.VersionCleanup:
{
// Destructive: this DELETES old file versions. No CSV exporter exists, so the
// output is always the HTML summary of what was removed.
var opts = new VersionCleanupOptions(o.LibraryTitles, o.KeepLast, o.KeepFirst);
var bySite = await ScanSites<VersionCleanupResult>((ctx, _) => _version.DeleteOldVersionsAsync(ctx, opts, progress, ct));
return ReportMergeHelper.Build(bySite, mode, "versions", ts, ReportFormat.Html,
rs => _versionHtml.BuildHtml(rs, branding));
}
default:
throw new NotSupportedException($"Report type {schedule.Type} is not supported.");
}
}
private async Task<GeneratedReport> FailAsync(ScheduledReport schedule, string profileName, string error)
{
var report = new GeneratedReport
{
ProfileId = schedule.ProfileId,
ScheduledReportId = schedule.Id,
Type = schedule.Type,
Name = schedule.Name,
Status = ReportRunStatus.Failed,
Error = error
};
await _index.AddAsync(report);
await AuditAsync(profileName, schedule, ReportRunStatus.Failed, error);
return report;
}
private Task AuditAsync(string profileName, ScheduledReport schedule, ReportRunStatus status, string details)
=> _audit.AppendAsync(new AuditEntry
{
Action = "ScheduledReport",
ClientName = profileName,
Sites = new List<string>(),
Details = $"{status}: {schedule.Name} — {details}",
UserEmail = "system",
UserDisplay = "Scheduler",
UserRole = UserRole.Admin
});
}
@@ -0,0 +1,78 @@
using System.Collections.Concurrent;
namespace SharepointToolbox.Web.Services.Reports;
/// <summary>
/// Process-wide coordinator for scheduled-report execution. Covers two operator needs:
/// • <b>Cancel an in-flight run</b> — every active run registers a linked
/// <see cref="CancellationTokenSource"/> keyed by schedule id, so the UI (or a
/// global stop) can abort a report that is currently executing, whether it was
/// started by the scheduler or by "Run now".
/// • <b>Pause future runs</b> — a global flag the background scheduler honours,
/// letting an admin suspend all cadence-triggered runs at once without toggling
/// each schedule's <c>Enabled</c> flag.
///
/// In-memory and singleton. The pause flag does NOT survive a process restart (a
/// restart resumes the scheduler); per-schedule <c>Enabled</c> flags persist and are
/// the durable way to keep a schedule off.
/// </summary>
public sealed class ScheduledRunCoordinator
{
private readonly ConcurrentDictionary<string, CancellationTokenSource> _active = new();
private volatile bool _paused;
/// <summary>True while the scheduler is globally paused (no schedules fire).</summary>
public bool IsPaused => _paused;
public void Pause() => _paused = true;
public void Resume() => _paused = false;
/// <summary>True while a run is registered for this schedule id.</summary>
public bool IsRunning(string scheduleId) => _active.ContainsKey(scheduleId);
/// <summary>Snapshot of schedule ids with a run in progress.</summary>
public IReadOnlyCollection<string> RunningIds => _active.Keys.ToArray();
/// <summary>
/// Registers a run for <paramref name="scheduleId"/> and returns a token that trips
/// when either the caller's <paramref name="linked"/> token (e.g. app shutdown) or a
/// <see cref="Cancel"/>/<see cref="CancelAll"/> call fires. Returns <c>null</c> if a
/// run is already registered for this schedule — callers must skip to avoid overlap.
/// Always pair a non-null return with <see cref="Complete"/> in a <c>finally</c>.
/// </summary>
public CancellationToken? TryBegin(string scheduleId, CancellationToken linked)
{
var cts = CancellationTokenSource.CreateLinkedTokenSource(linked);
if (!_active.TryAdd(scheduleId, cts)) { cts.Dispose(); return null; }
return cts.Token;
}
/// <summary>Deregisters and disposes the run for this schedule id.</summary>
public void Complete(string scheduleId)
{
if (_active.TryRemove(scheduleId, out var cts)) cts.Dispose();
}
/// <summary>Signals cancellation to the run for this schedule id. Returns false if none.</summary>
public bool Cancel(string scheduleId)
{
if (_active.TryGetValue(scheduleId, out var cts))
{
try { cts.Cancel(); return true; }
catch (ObjectDisposedException) { return false; } // completed between lookup and cancel
}
return false;
}
/// <summary>Signals cancellation to every run in progress. Returns the count signalled.</summary>
public int CancelAll()
{
int n = 0;
foreach (var cts in _active.Values)
{
try { cts.Cancel(); n++; }
catch (ObjectDisposedException) { /* completed concurrently */ }
}
return n;
}
}
+60 -3
View File
@@ -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|<guid> → AAD security group
// c:0o.c|federateddirectoryclaimprovider|<guid> → M365 group members (group-connected/Teams sites)
// c:0o.c|federateddirectoryclaimprovider|<guid>_o → M365 group owners
// The M365 cases are how modern group-connected sites grant access; without expanding them a
// user who is "just a member of the site" never appears in a user-centric audit.
internal static bool IsAadGroup(string login) =>
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<IEnumerable<ResolvedMember>> ResolveAadGroupAsync(
@@ -122,4 +143,40 @@ public class SharePointGroupResolver : ISharePointGroupResolver
return Enumerable.Empty<ResolvedMember>();
}
}
// M365 group owners (the "…_o" claim). Owners are a direct, non-nested collection, so no
// transitive expansion is needed — owners cannot themselves be groups.
private static async Task<IEnumerable<ResolvedMember>> ResolveAadGroupOwnersAsync(
GraphServiceClient graphClient, string aadGroupId, CancellationToken ct)
{
try
{
var response = await graphClient.Groups[aadGroupId].Owners.GraphUser.GetAsync(config =>
{
config.QueryParameters.Select = new[] { "displayName", "userPrincipalName" };
config.QueryParameters.Top = 999;
}, ct);
if (response?.Value is null) return Enumerable.Empty<ResolvedMember>();
var owners = new List<ResolvedMember>();
var iter = PageIterator<GraphUser, GraphUserCollectionResponse>.CreatePageIterator(
graphClient, response,
user =>
{
if (ct.IsCancellationRequested) return false;
owners.Add(new ResolvedMember(
user.DisplayName ?? user.UserPrincipalName ?? "Unknown",
user.UserPrincipalName ?? string.Empty));
return true;
});
await iter.IterateAsync(ct);
return owners;
}
catch (OperationCanceledException) { throw; }
catch (Exception ex)
{
Log.Warning("Could not resolve AAD group '{Id}' owners: {Error}", aadGroupId, ex.Message);
return Enumerable.Empty<ResolvedMember>();
}
}
}
+30 -107
View File
@@ -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;
/// <summary>
/// Delegated CSOM implementation of <see cref="ISiteDiscoveryService"/>.
/// Enumerates every site collection in a tenant via the SharePoint tenant-admin endpoint
/// (<c>Tenant.GetSitePropertiesFromSharePointByFilters</c>), paging through all results.
/// The auth model only changes how the admin-host context is built:
///
/// Enumerates every site collection via the SharePoint tenant admin endpoint
/// (<c>Tenant.GetSitePropertiesFromSharePointByFilters</c>), 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 (<see cref="Services.Reports"/>), which
/// relies only on the SharePoint <c>Sites.FullControl.All</c> application permission the cert
/// app already holds. (The earlier Graph <c>/sites/getAllSites</c> path was dropped: it needs
/// a separate Graph <c>Sites.Read.All</c> grant the cert app is not provisioned with, so it
/// returned empty/403 and tenant-wide audits silently fell back to the root site alone.)
/// • Delegated profiles build the admin context through the session manager; this requires the
/// signed-in user to be a SharePoint administrator.
///
/// The Graph <c>/sites?search=*</c> endpoint was deliberately abandoned: it ranks
/// by relevance and is capped server-side, so it silently dropped sites and
/// returned varying counts run-to-run. <c>/sites/getAllSites</c> is app-only and
/// 403s on a delegated user token. The tenant admin enumeration is the only path
/// that returns the complete, stable set under the app's delegated auth model.
/// The Graph <c>/sites?search=*</c> endpoint was deliberately abandoned for both: it ranks by
/// relevance and is capped server-side, silently dropping sites and returning varying counts.
/// </summary>
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<IReadOnlyList<SiteInfo>> 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<SiteInfo>();
SPOSitePropertiesEnumerable page;
do
{
ct.ThrowIfCancellationRequested();
page = await FetchPageWithColdTokenRetryAsync(ctx, tenant, filter, ct);
foreach (var sp in page)
{
var url = sp.Url ?? string.Empty;
if (string.IsNullOrEmpty(url)) continue;
// Belt-and-braces: PersonalSiteFilter.Exclude already drops OneDrive.
if (url.Contains("/personal/", StringComparison.OrdinalIgnoreCase)) continue;
var title = string.IsNullOrEmpty(sp.Title) ? url : sp.Title;
results.Add(new SiteInfo(url, title));
}
// NextStartIndexFromSharePoint is empty/null once the last page is returned.
filter.StartIndex = page.NextStartIndexFromSharePoint;
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<SPOSitePropertiesEnumerable> FetchPageWithColdTokenRetryAsync(
ClientContext ctx, Tenant tenant, SPOSitePropertiesEnumerableFilter filter, CancellationToken ct)
{
for (int attempt = 1; ; attempt++)
{
try
{
var page = tenant.GetSitePropertiesFromSharePointByFilters(filter);
ctx.Load(page);
await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(ctx, null, ct);
return page;
}
catch (SharePointAccessDeniedException ex) when (attempt < MaxColdTokenAttempts)
{
var delay = TimeSpan.FromSeconds(BackoffBaseSeconds * attempt);
Serilog.Log.Warning(
"Tenant admin endpoint denied during site discovery (attempt {N}/{Max}); " +
"retrying in {Delay}s. {Err}",
attempt, MaxColdTokenAttempts, delay.TotalSeconds, ex.Message);
await Task.Delay(delay, ct);
}
}
}
// https://contoso.sharepoint.com[/sites/Foo] → https://contoso-admin.sharepoint.com
private static string BuildAdminUrl(string tenantUrl)
{
if (!Uri.TryCreate(tenantUrl, UriKind.Absolute, out var uri))
return tenantUrl;
var adminHost = uri.Host.Replace(".sharepoint.com", "-admin.sharepoint.com",
StringComparison.OrdinalIgnoreCase);
return $"{uri.Scheme}://{adminHost}";
// 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);
}
}
+145 -38
View File
@@ -7,19 +7,27 @@ public class UserAccessAuditService : IUserAccessAuditService
{
private readonly IPermissionsService _permissionsService;
private readonly IElevationCoordinator _elevation;
private readonly ISharePointGroupResolver _groupResolver;
private static readonly HashSet<string> HighPrivilegeLevels = new(StringComparer.OrdinalIgnoreCase)
{
"Full Control", "Site Collection Administrator"
};
public UserAccessAuditService(IPermissionsService permissionsService, IElevationCoordinator elevation)
private static readonly IReadOnlyDictionary<string, IReadOnlyList<ResolvedMember>> NoGroupMembers =
new Dictionary<string, IReadOnlyList<ResolvedMember>>();
public UserAccessAuditService(
IPermissionsService permissionsService,
IElevationCoordinator elevation,
ISharePointGroupResolver groupResolver)
{
_permissionsService = permissionsService;
_elevation = elevation;
_groupResolver = groupResolver;
}
public async Task<IReadOnlyList<UserAccessEntry>> AuditUsersAsync(
public async Task<UserAccessAuditResult> AuditUsersAsync(
ISessionManager sessionManager,
TenantProfile currentProfile,
IReadOnlyList<string> 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<UserAccessEntry>();
if (targets.Count == 0) return new UserAccessAuditResult(Array.Empty<UserAccessEntry>(), 0, 0, 0);
var allEntries = new List<UserAccessEntry>();
// Per-site resilience: when auditing many sites (e.g. the whole tenant), one bad site
// must not abort the run. Access-denied is the expected "tech has no access here" case
// and is skipped quietly; any other error is skipped but counted as a failure so it can
// be surfaced. Cancellation always propagates.
int deniedSites = 0, failedSites = 0;
for (int i = 0; i < sites.Count; i++)
{
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<UserAccessEntry> TransformEntries(
IReadOnlyList<PermissionEntry> permEntries, HashSet<string> targets, SiteInfo site)
/// <summary>
/// Resolves every SharePoint group referenced by <paramref name="permEntries"/> to its
/// member set (expanding nested AAD groups via Graph), so group-granted access can be
/// attributed to the target user. Returns an empty map if there are no group principals
/// or resolution fails (the audit then falls back to direct grants only rather than abort).
/// Shared by the interactive audit and the background report scheduler.
/// </summary>
public static async Task<IReadOnlyDictionary<string, IReadOnlyList<ResolvedMember>>> ResolveGroupMembersAsync(
ISharePointGroupResolver resolver,
Microsoft.SharePoint.Client.ClientContext ctx,
TenantProfile profile,
IReadOnlyList<PermissionEntry> permEntries,
CancellationToken ct)
{
var groupNames = permEntries
.Where(e => string.Equals(e.PrincipalType, "SharePointGroup", StringComparison.OrdinalIgnoreCase))
.Select(e => e.Users) // group title; matches GrantedThrough "SharePoint Group: {title}"
.Where(n => !string.IsNullOrWhiteSpace(n))
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToList();
if (groupNames.Count == 0) return NoGroupMembers;
try
{
return await resolver.ResolveGroupsAsync(ctx, profile, groupNames, ct);
}
catch (OperationCanceledException) { throw; }
catch (Exception ex)
{
Serilog.Log.Warning("User-access audit: SP group expansion failed on {Url}: {Error}", ctx.Url, ex.Message);
return NoGroupMembers;
}
}
/// <summary>
/// Projects raw permission entries for one site into per-user access entries, keeping only
/// rows touching one of <paramref name="targets"/> (substring match on login). Direct user
/// grants match the principal's own login; SharePoint-group grants match against the group's
/// expanded membership in <paramref name="groupMembers"/> (see <see cref="ResolveGroupMembersAsync"/>),
/// so access held through a group is attributed to its members. Exposed for the background
/// report scheduler, which reuses this projection under app-only auth.
/// </summary>
internal static IEnumerable<UserAccessEntry> TransformEntries(
IReadOnlyList<PermissionEntry> permEntries, HashSet<string> targets, SiteInfo site,
IReadOnlyDictionary<string, IReadOnlyList<ResolvedMember>>? groupMembers = null)
{
foreach (var entry in permEntries)
{
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);
}
}
}
+12 -12
View File
@@ -26,19 +26,19 @@
</ItemGroup>
<ItemGroup>
<PackageReference Include="Azure.Identity" Version="1.14.1" />
<PackageReference Include="Azure.Identity" Version="1.21.0" />
<PackageReference Include="CsvHelper" Version="33.1.0" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.OpenIdConnect" Version="10.0.0" />
<PackageReference Include="Microsoft.Graph" Version="5.74.0" />
<PackageReference Include="Microsoft.Kiota.Abstractions" Version="1.22.2" />
<PackageReference Include="Microsoft.Kiota.Authentication.Azure" Version="1.22.2" />
<PackageReference Include="Microsoft.Kiota.Http.HttpClientLibrary" Version="1.22.2" />
<PackageReference Include="Microsoft.Kiota.Serialization.Form" Version="1.22.2" />
<PackageReference Include="Microsoft.Kiota.Serialization.Json" Version="1.22.2" />
<PackageReference Include="Microsoft.Kiota.Serialization.Multipart" Version="1.22.2" />
<PackageReference Include="Microsoft.Kiota.Serialization.Text" Version="1.22.2" />
<PackageReference Include="PnP.Framework" Version="1.18.0" />
<PackageReference Include="Serilog.AspNetCore" Version="9.0.0" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.OpenIdConnect" Version="10.0.9" />
<PackageReference Include="Microsoft.Graph" Version="6.2.0" />
<PackageReference Include="Microsoft.Kiota.Abstractions" Version="2.0.0" />
<PackageReference Include="Microsoft.Kiota.Authentication.Azure" Version="2.0.0" />
<PackageReference Include="Microsoft.Kiota.Http.HttpClientLibrary" Version="2.0.0" />
<PackageReference Include="Microsoft.Kiota.Serialization.Form" Version="2.0.0" />
<PackageReference Include="Microsoft.Kiota.Serialization.Json" Version="2.0.0" />
<PackageReference Include="Microsoft.Kiota.Serialization.Multipart" Version="2.0.0" />
<PackageReference Include="Microsoft.Kiota.Serialization.Text" Version="2.0.0" />
<PackageReference Include="PnP.Framework" Version="1.19.0" />
<PackageReference Include="Serilog.AspNetCore" Version="10.0.0" />
<PackageReference Include="Serilog.Sinks.File" Version="7.0.0" />
</ItemGroup>
+3
View File
@@ -1,5 +1,8 @@
{
"DataFolder": "/data",
"App": {
"Domain": ""
},
"Oidc": {
"TenantId": "YOUR_ENTRA_TENANT_ID",
"ClientId": "YOUR_SPTB_APP_CLIENT_ID",
+96
View File
@@ -0,0 +1,96 @@
#requires -Version 5.1
<#
.SYNOPSIS
Build the SharepointToolbox.Web Docker image and push it to the Gitea
container registry at git.azuze.fr.
.DESCRIPTION
Builds the image from the local Dockerfile, tags it for the Gitea registry
(both the given tag and :latest), logs in, and pushes.
Login uses a Gitea access token, NOT your account password. Create one at:
git.azuze.fr -> Settings -> Applications -> Generate New Token
(scope: write:package — read:package too if you also pull)
Provide credentials via -Username / -Token, or set env vars
GITEA_USER / GITEA_TOKEN, or you'll be prompted.
.EXAMPLE
.\build-and-push.ps1
Builds and pushes :latest (prompts for token if not cached).
.EXAMPLE
.\build-and-push.ps1 -Tag v1.2.0
Builds and pushes both :v1.2.0 and :latest.
.EXAMPLE
$env:GITEA_USER='kawa'; $env:GITEA_TOKEN='xxxx'; .\build-and-push.ps1 -Tag v1.2.0
#>
[CmdletBinding()]
param(
[string]$Tag = 'latest',
[string]$Registry = 'git.azuze.fr',
[string]$Owner = 'kawa',
[string]$Image = 'sptb-web',
[string]$Username = $env:GITEA_USER,
[string]$Token = $env:GITEA_TOKEN,
[switch]$SkipLatest, # don't also tag/push :latest when -Tag is something else
[switch]$NoCache # build with --no-cache
)
$ErrorActionPreference = 'Stop'
Set-Location -LiteralPath $PSScriptRoot
function Fail($msg) { Write-Host "ERROR: $msg" -ForegroundColor Red; exit 1 }
function Step($msg) { Write-Host "==> $msg" -ForegroundColor Cyan }
# --- preflight ---------------------------------------------------------------
if (-not (Get-Command docker -ErrorAction SilentlyContinue)) {
Fail 'docker not on PATH. Start Docker Desktop / install docker CLI.'
}
try { docker info *> $null } catch { Fail 'docker daemon not reachable. Is Docker Desktop running?' }
if (-not (Test-Path .\Dockerfile)) { Fail "Dockerfile not found in $PSScriptRoot" }
$repo = "$Registry/$Owner/$Image"
$primary = "${repo}:$Tag"
$pushLatest = (-not $SkipLatest) -and ($Tag -ne 'latest')
# --- build -------------------------------------------------------------------
Step "Building $primary"
$buildArgs = @('build', '-t', $primary)
if ($pushLatest) { $buildArgs += @('-t', "${repo}:latest") }
if ($NoCache) { $buildArgs += '--no-cache' }
$buildArgs += '.'
docker @buildArgs
if ($LASTEXITCODE -ne 0) { Fail 'docker build failed.' }
# --- login -------------------------------------------------------------------
if (-not $Username) { $Username = Read-Host "Gitea username for $Registry" }
if (-not $Token) {
$sec = Read-Host "Gitea access token for $Registry (input hidden)" -AsSecureString
$Token = [Runtime.InteropServices.Marshal]::PtrToStringAuto(
[Runtime.InteropServices.Marshal]::SecureStringToBSTR($sec))
}
if (-not $Username -or -not $Token) { Fail 'Username/token required to push.' }
Step "Logging in to $Registry as $Username"
$Token | docker login $Registry --username $Username --password-stdin
if ($LASTEXITCODE -ne 0) { Fail 'docker login failed (bad token? wrong scope?).' }
# --- push --------------------------------------------------------------------
Step "Pushing $primary"
docker push $primary
if ($LASTEXITCODE -ne 0) { Fail "docker push failed for $primary" }
if ($pushLatest) {
Step "Pushing ${repo}:latest"
docker push "${repo}:latest"
if ($LASTEXITCODE -ne 0) { Fail "docker push failed for ${repo}:latest" }
}
Write-Host ""
Write-Host "Done. Pushed:" -ForegroundColor Green
Write-Host " $primary"
if ($pushLatest) { Write-Host " ${repo}:latest" }
Write-Host ""
Write-Host "Pull with: docker pull $primary"
+45
View File
@@ -0,0 +1,45 @@
# Runs the prebuilt image from the Gitea registry (no local build).
# docker compose -f docker-compose.prebuilt.yml pull
# docker compose -f docker-compose.prebuilt.yml up -d
#
# Pin a version by overriding the tag: SPTB_TAG=v1.2.0 docker compose ...
# Set the OIDC secrets via a .env file next to this compose file (see below).
services:
sptb-web:
image: git.azuze.fr/kawa/sptb-web:${SPTB_TAG:-latest}
container_name: sptb-web
ports:
- "8080:8080"
volumes:
- sptb-data:/data
environment:
- ASPNETCORE_ENVIRONMENT=Production
- DataFolder=/data
# Public domain the app is reached at (e.g. sptb.example.com). The SharePoint-connect
# redirect URI is derived from it as <domain>/connect/callback.
- App__Domain=${App__Domain:-}
# OIDC config — overrides the placeholder values baked into appsettings.json.
# Authority is derived from TenantId in code; do NOT set an Authority key.
# Put real values in a .env file beside this compose file (NO quotes around
# values — the list form embeds literal quotes and breaks discovery):
# Oidc__TenantId=<entra-tenant-guid>
# Oidc__ClientId=<app-client-id>
# Oidc__ClientSecret=<app-client-secret>
- Oidc__TenantId=${Oidc__TenantId:-}
- Oidc__ClientId=${Oidc__ClientId:-}
- Oidc__ClientSecret=${Oidc__ClientSecret:-}
# Optional: seed first admin while the user store is empty (local form login).
- Bootstrap__AdminEmail=${Bootstrap__AdminEmail:-}
- Bootstrap__AdminPassword=${Bootstrap__AdminPassword:-}
restart: unless-stopped
healthcheck:
# /account/login is anonymous and returns 200; -f fails on >=400.
test: ["CMD", "curl", "-fsS", "http://localhost:8080/account/login"]
interval: 30s
timeout: 10s
retries: 3
start_period: 30s
volumes:
sptb-data:
driver: local
+8 -1
View File
@@ -12,9 +12,16 @@ services:
environment:
- ASPNETCORE_ENVIRONMENT=Production
- DataFolder=/data
# Public domain the app is reached at (e.g. sptb.example.com). The SharePoint-connect
# redirect URI (<domain>/connect/callback) is derived from it. Set your OIDC values
# here too, or pass an env file.
- App__Domain=${App__Domain:-}
restart: unless-stopped
healthcheck:
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:8080/"]
# /account/login is anonymous and returns 200 (the app root now 302-redirects
# unauthenticated users, which would read as unhealthy). curl is installed in
# the image; -f fails on >=400.
test: ["CMD", "curl", "-fsS", "http://localhost:8080/account/login"]
interval: 30s
timeout: 10s
retries: 3
+35 -18
View File
@@ -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); }