Commit Graph

58 Commits

Author SHA1 Message Date
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
kawa 1b0f4ce588 Merge branch 'main' of https://git.azuze.fr/kawa/SharepointToolbox-Web 2026-06-04 14:48:18 +02:00
kawa ce2f0512cb Fixed site discovery process that did not return all the sites 2026-06-04 14:48:08 +02:00
kawa 0fb34d8ca2 Fixed site discovery process that did not return all the sites 2026-06-04 11:32:34 +02:00
kawa 95aca34b31 Merge branch 'main' of https://git.azuze.fr/kawa/SharepointToolbox-Web 2026-06-03 11:04:28 +02:00
kawa ddd822f0d1 Merge branch 'main' of https://git.azuze.fr/kawa/SharepointToolbox-Web 2026-06-03 11:04:23 +02:00
kawa 3be9eeb1ca Merge branch 'main' of https://git.azuze.fr/kawa/SharepointToolbox-Web 2026-06-03 09:50:35 +02:00
kawa a5c57ba1e8 Merge branch 'main' of https://git.azuze.fr/kawa/SharepointToolbox-Web 2026-06-03 09:50:25 +02:00
kawa 9de0dc0867 Merge branch 'main' of https://git.azuze.fr/kawa/SharepointToolbox-Web 2026-06-02 17:39:58 +02:00
kawa e6ae06e29d Merge branch 'main' of https://git.azuze.fr/kawa/SharepointToolbox-Web 2026-06-02 17:39:52 +02:00
kawa 1b756af913 Merge branch 'main' of https://git.azuze.fr/kawa/SharepointToolbox-Web 2026-06-02 17:13:26 +02:00
kawa 78881b83a2 Merge branch 'main' of https://git.azuze.fr/kawa/SharepointToolbox-Web 2026-06-02 17:13:09 +02:00
kawa b4c4e4efcc Merge branch 'main' of https://git.azuze.fr/kawa/SharepointToolbox-Web 2026-06-02 15:46:13 +02:00
kawa d69c3290d8 Merge pull request 'Add report logos and configurable folder scan depth' (#2) from feat/report-logos-and-scan-depth into main
Reviewed-on: #2
2026-06-02 15:46:05 +02:00
kawa b23bcd25ee Merge pull request 'Add report logos and configurable folder scan depth' (#2) from feat/report-logos-and-scan-depth into main
Reviewed-on: #2
2026-06-02 15:02:52 +02:00
kawa db06874343 Merge branch 'main' into feat/report-logos-and-scan-depth 2026-06-02 15:02:40 +02:00
kawa 5df7b72800 Add report logos and configurable folder scan depth
Report branding (top-left MSP logo, top-right client logo):
- Add MspLogo to AppSettings; client logo already on TenantProfile
- IUserSessionService.CurrentBranding composes MSP + active profile logo
- New reusable LogoUpload component (InputFile -> base64 LogoData, 512KB cap)
- MSP logo upload in Settings; optional client logo in profile create/edit
- Wire ReportBranding into all 6 HTML export pages
- Fix EditProfile dropping ClientLogo on edit

Storage metrics: expose folder scan depth (0-20) in scan options UI,
passed to existing StorageScanOptions.FolderDepth recursion.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 14:56:49 +02:00
kawa 52b91f7de1 Merge pull request 'Register created app as public client (fix connect AADSTS7000218)' (#1) from fix/auto-elevate-ownership into main
Reviewed-on: #1
2026-06-02 14:43:33 +02:00
kawa 881f3a8bac Add backoff-retry to elevation for transient admin 403 and grant lag
Logs showed the failure was a transient 403 on the tenant admin endpoint
(loading CurrentUser on -admin.sharepoint.com returned E_ACCESSDENIED on a
cold token), and that re-running the operation a few seconds later succeeded.
The site-collection admin grant is also eventually consistent on Group/Teams
sites, taking a few seconds to reach the content endpoint.

Retry both stages with backoff (3s, 6s, 9s; 4 attempts) instead of failing
on the first denial:
- ElevateAsync retries the admin-endpoint grant on transient access-denied; a
  genuine lack of tenant-admin rights still surfaces after retries exhaust.
- After a successful grant, the post-elevation operation retries on continued
  access-denied to absorb grant-propagation lag.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 14:39:29 +02:00
kawa e4125c6643 Instrument elevation path to diagnose ineffective grants
A SharePoint admin reported the grant runs without a logged error yet the
account never appears as site-collection admin on Group/Teams sites. The
failure was invisible: ElevateAsync called ExecuteQueryAsync directly (no
enrichment/logging) and the coordinator only surfaced elevate failures on the
page, not to Serilog.

- Route the admin-endpoint ExecuteQuery through ExecuteQueryRetryHelper so a
  denial there is enriched (serverErrorType/httpStatus) and logged.
- Log the resolved login and SetSiteAdmin acceptance in OwnershipElevationService.
- Log elevate failures to Serilog in the coordinator.
- Add a post-elevation verify that reads CurrentUser.IsSiteAdmin on the target
  site so logs distinguish a failed/no-op grant from a scan failing for another
  reason. Diagnostic only; never throws into the operation flow.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 14:35:48 +02:00