- 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>
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>
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>
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>
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>
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>
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>
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>
@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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>