Commit Graph

1317 Commits

Author SHA1 Message Date
Oliver Kieselbach 7da5d482f1 fix(mcp/deps): bump fast-uri 3.1.0->3.1.2 to patch 2 high-sev advisories
Resolves GHSA-q3j6-qgpj-74h6 (path traversal via percent-encoded dot
segments) and GHSA-v39h-62p7-jpjc (host confusion via percent-encoded
authority delimiters). Transitive via @modelcontextprotocol/sdk -> ajv.
Lockfile-only change, no API surface impact.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 22:57:07 +02:00
Oliver Kieselbach 871ec31bf6 feat(backend/health): surface SignalR free-tier quota in /health/detailed
HealthCheckService now runs a fourth check (SignalR Quota) alongside Storage,
Processing, and Agent Binaries. Live read of ConnectionCount (max/1h) and
MessageCount (total since 00:00 UTC) via the same IAzureMonitorMetricsReader
that backs the maintenance-time Ops watcher - no cache; the data is already
3-5min stale from Azure Monitor's side, and a /health/detailed re-check is
expected to give fresh values during incident response.

Reuses MaintenanceService's internal classifier (CalculatePercent,
ClassifySignalRQuotaTier) so the watcher and the dashboard agree on the
80%/95% boundaries down to the floor-vs-ceil rounding.

Tier mapping: None -> healthy, Warning -> warning, Critical -> unhealthy.
Worst-of-both-metrics wins. PerformAllChecksAsync now uses a tri-state
combiner (any unhealthy / any warning / else healthy) so a Warning tier
correctly tints the OverallStatus bar yellow instead of red.

Fail-soft:
- SignalRResourceId unset -> Status="unknown" with "watcher not configured"
  (excluded from OverallStatus combiner so dev/local stays healthy)
- Both metric reads null -> Status="unhealthy" pointing at the most likely
  cause (Function App MI missing 'Monitoring Reader' on the SignalR resource)
- One read null -> "no data" for that row, normal classification for the other

Frontend unchanged: the existing /health-check Backend Services card loop
renders the new check with Connections, Messages, and Resource as Details.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 22:55:42 +02:00
Oliver Kieselbach 5cb7480bb4 updated Changelog 2026-05-10 22:35:33 +02:00
Oliver Kieselbach 4af28fefbe added V2 rollout notify banner to dashboard 2026-05-10 22:19:15 +02:00
Oliver Kieselbach d51bde6017 refactor(release): parametric per-line agent build pipeline
Replaces V1/V2-hardcoded build + bootstrap with a single parametric
pipeline that scales to V3+ without rewrites. Full architecture in
.claude/plans/v2-cutover.md.

Stable namespace (Bootstrap-side, never renamed): generic Bootstrap
script, agent owns its --install defaults so it works for any line.
Versioned namespace (per-line, SelfUpdater-side): isolated *-v{N}.*
blobs; SelfUpdater never crosses lines or touches stable.

Backend GetAgentConfig dispatches per X-Agent-Version-Major to
AdminConfiguration.GetAgentLine(int) -> LatestAgentV{N}* fields.
Future V3 = add field set + switch arm. TableConfigRepository.Map
falls back to legacy unsuffixed columns for transparent migration.

V1 SelfUpdater now reads version-v1.json (would have been clobbered
by the V2 cutover otherwise). V2 agent owns its own 600s TenantId-wait
default so the Bootstrap calls --install plain.

Tests: 3289 across 6 suites, all green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 19:36:53 +02:00
Oliver Kieselbach 8cbc523f89 fix(web/telemetry): drop session_searched mount-fire, track signalr reconnect downtime
session_searched fired on every dashboard mount with empty query, inflating
event volume ~3x and making unique-user counts unusable. Skip empty queries
and emit queryLength as a measurement instead of the now-redundant hasQuery.

signalr_reconnected lacked any latency signal. Capture performance.now() in
onreconnecting and emit downtimeMs in onreconnected so recovery quality
(not just existence) shows up in App Insights.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 11:31:28 +02:00
Oliver Kieselbach 0b80f09a74 refactor(notifications): bell on pure SignalR push, kill 60s polling
The two notification-bell React contexts polled GET /api/notifications and
GET /api/global/notifications every 60s regardless of activity, generating
3017+618 = 3635 calls/24h with near-zero hit rate (notifications are emitted
sparsely). SignalR was already used app-wide for live session updates; the
bell was the lone hold-out on a pull model.

Switch to the same push model. Steady-state traffic drops ~86% and the bell
updates sub-second instead of within 60s.

Architecture (Option C+):
  - Mount  -> 1x HTTP fetch (cold-start hydration via existing GET endpoint)
  - SignalR onreconnected -> re-fetch (covers the disconnect window)
  - SignalR push -> live append/dismiss
  - No periodic polling, no setInterval

Audience-tier security: TenantNotificationAudienceCatalog still drives
visibility (Member vs Admin), but enforcement moves into SignalR group
membership: tenant-{id}-notify-member is joined by every tenant member,
tenant-{id}-notify-admin only by Tenant Admins / Global Admins. The
AddToGroup function fail-closes with 403 on Member-tier joins to the
admin group, so Admin-tier notifications cannot leak via push.

Backend
  - ISignalRNotificationService extracted; SignalRNotificationService
    implements it. Resolves the long-standing test-mocking blocker
    (the real service's constructor validates AzureSignalRConnectionString
    and BuildServiceManager(); previously you could not unit-test
    consumers). Tenant + Global NotificationService consume the
    interface; the three legacy callers (Analyze, Ingest, VulnCorrelate)
    still bind to the concrete class -- migrate per test need.
  - Six new push methods: SendTenantNotification{,Dismissed,DismissedAll}
    and SendGlobalNotification{,Dismissed,DismissedAll}. Tenant variant
    routes to the audience-correct group.
  - Tenant + Global service push after a successful repo write
    (fire-and-forget; never throws into the caller).
  - SignalRGroupHelper: parsing handles the two new -notify-{member,admin}
    suffixes; new IsTenantNotifyAdminGroup predicate; group-name builders.
  - SignalRAddToGroupFunction: explicit 403 when a non-admin attempts
    to join the admin-tier group.

Frontend
  - TenantNotificationContext: setInterval gone. useSignalR() to
    join member-group always, admin-group iff isTenantAdmin || isGlobalAdmin.
    Listens for tenantNotification (append + dedupe), tenantNotificationDismissed
    (filter), tenantNotificationsDismissedAll (clear). onreconnected
    triggers re-fetch.
  - GlobalNotificationContext: same pattern against global-admins group.
  - app/layout.tsx: SignalRProvider hoisted above the notification
    providers so useSignalR() resolves inside them.

Tests
  - SignalRGroupHelperTests (8): parsing for all four group formats
    incl. new -notify suffixes, IsTenantNotifyAdminGroup predicate.
  - TenantNotificationServiceSignalRPushTests (8): audience-correct push
    on Create (Admin / Member / Unknown -> Admin fail-closed), no-push
    on repo failure, no-push on DismissAll(count=0), no-push on empty
    tenantId.
  - FakeSignalRNotificationService: in-memory spy implementing the
    new interface; replaces the unmockable real service in tests.
  - AuthFunctionSideEffectTests + TenantNotificationServiceTests:
    constructor signatures updated for the new SignalR dep.

Suites
  Backend xUnit: 1161/1161 (was 1146, +15 net).
  Web vitest:    259/259 (unchanged; existing notification-API +
                 gate tests still cover).
  Solution build: 0 warnings, 0 errors.

Expected post-deploy reduction (24h):
  GET GetTenantNotifications:  3017 -> ~50
  GET GetGlobalNotifications:   618 -> ~10
  POST AddToGroup:              ~246 -> ~350-450 (two joins per tab)
  Net bell load:               -86%, sub-second live UX.

Disconnect tradeoff (accepted): if SignalR drops without reconnect
(rare -- withAutomaticReconnect retries 1/2/4/8/16/30s ... forever),
the bell shows the snapshot from the last fetch but no live updates
until next reload. The initial-3-retry give-up path of the SignalR
client still gets the user a hydrated bell because the HTTP fetch
runs independently on mount.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 18:32:22 +02:00
Oliver Kieselbach bc1e1e405a feat(web/admin): expose 4 SignalR-quota EventTypes in OpsAlertRules UI
Adds toggles for SignalRConnectionsHigh, SignalRConnectionsCritical,
SignalRMessagesHigh, SignalRMessagesCritical to the Security category of
the OpsAlertRules section, so operators can route them to Telegram /
Teams / Slack via the existing alert-dispatch pipeline.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 18:07:02 +02:00
Oliver Kieselbach bc3cefa790 feat(backend/observability): SignalR free-quota watcher with tiered OpsEvents
Hourly TimerFunction queries Azure Monitor for SignalR ConnectionCount (max
over last hour) and MessageCount (sum since UTC midnight) and emits Warning
(>=80%) / Error (>=95%) OpsEvents when the free-tier limits (20 connections,
20k messages/day) are approached. Mirror of the cert-expiry pattern - global
TenantId=null events, dedup'd to one emission per EventType per UTC day.

Fail-soft: skips silently when SignalRResourceId app setting is unset, logs
warning on Azure Monitor auth/query errors. Function App's managed identity
needs 'Monitoring Reader' on the SignalR resource.

Also called from the manual maintenance trigger so 'Run Maintenance Now'
exercises every health check.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 18:06:56 +02:00
Oliver Kieselbach 5a4309dc9a fix(backend/maintenance): also run cert-expiry watcher in manual trigger path
77fde56f hooked CheckEmbeddedCertExpiryAsync only into RunAllAsync (the 2h
timer). Manual triggers via POST /api/maintenance/trigger call RunManualAsync,
which is a different code path - so manual runs were silently skipping the
watcher and gave a false sense of "no alert means bucket is healthy". Mirror
the timer-path call inside the !aggregateOnly block so manual ops verification
exercises the watcher just like the scheduled run does.

Aggregate-only manual runs (date catch-up) still skip it, since they're for
backfilling historical metrics and don't need cert checks.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 16:35:16 +02:00
Oliver Kieselbach 2a9aa606c9 feat(web/admin): expose 4 cert-expiry EventTypes in OpsAlertRules UI
The Alert Rules section in the admin UI builds its rule grid from a
hardcoded OPS_EVENT_TYPES catalog. Without this update, the four
EventTypes added by f2257a4b would not appear as toggle rows, so the
operator could not subscribe Telegram/Teams/Slack to them despite the
backend already emitting the events.

Added under the existing Security category:
  - EmbeddedCertExpiringSoon  (set MinSeverity Warning)
  - EmbeddedCertExpiringUrgent (set MinSeverity Error)
  - EmbeddedCertExpired       (set MinSeverity Critical)
  - EmbeddedCertBundleEmpty   (set MinSeverity Critical)

After deploy: admin opens Settings -> Alerts, toggles the 4 rows on,
sets MinSeverity per row, enables Telegram + Chat ID, saves. From the
next maintenance run any cert-expiry event matching the threshold pings
the configured providers.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 16:21:25 +02:00
Oliver Kieselbach f2257a4b02 fix(backend/security): cert-expiry watcher uses bucket-level logic, not per-cert
77fde56f's per-cert alarm fires for every expiring cert independently. That
is wrong: when a Root or Intermediate has a fresh successor in the bundle
(e.g. intune-intermediate-mdm-2026.pem alongside intune-intermediate-mdm-2028
.pem), the older cert is just a chain-continuity helper for in-flight device
certs. Alarming on it would page the operator to "rotate" something that is
already rotated.

Refactor to bucket-level: per role (Root, Intermediate), pick the cert with
the MAXIMUM NotAfter and classify only that. Older expiring/expired
siblings are silent. Operator only gets paged when the freshest itself
nears expiry, i.e. when there is genuinely no successor yet.

Concrete behaviour change for current bundle (today 2026-05-08):
  Roots:         freshest is intune-root-2030.pem (1591d) -> silent
  Intermediates: freshest is intune-intermediate-mdm-2028.pem (859d) -> silent
The expired intune-intermediate-mdm-2026.pem (-3d) no longer triggers a
spurious Critical because mdm-2028 has it covered.

Tests: 7 new EvaluateBucket cases (empty, single fresh, single soon, old+
fresh successor stays silent, old+soon successor fires on successor, all
expired falls back to freshest-expired, two future successors). Total 38
cert-related tests, full suite 1105/1105.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 16:16:12 +02:00
Oliver Kieselbach 77fde56f30 feat(backend/security): cert-expiry watcher in maintenance with tiered OpsEvents
Adds proactive monitoring of the embedded Intune CA bundle so the operator
gets a Telegram ping months before agent mTLS would break. Bundle rotation
(re-exporting fresh PEMs from a corporate-enrolled device) is the only fix
and needs lead time - today's outage from 9af3de15 was rooted in exactly
this blind spot.

Hooks into the existing 2h MaintenanceService.RunAllAsync timer, iterates
the embedded roots + intermediates, and emits tiered OpsEvents on the
Security category:

  daysUntilExpiry  | Severity | EventType
  -----------------+----------+----------------------------
   <= 7 (or past)  | Critical | EmbeddedCertExpired
   <= 30           | Error    | EmbeddedCertExpiringUrgent
   <= 90           | Warning  | EmbeddedCertExpiringSoon
   else            | -        | (silent)

Plus a Critical EmbeddedCertBundleEmpty event if the loader returns no
roots at all (server misconfiguration / failed deploy).

Dedup is one event per cert per tier per UTC day, by querying today's
Security OpsEvents and matching on EventType + thumbprint extracted from
the Details JSON. Cold starts (Flex Consumption scale-to-zero) rebuild
the dedup index from the table, so repeated process restarts do not
multiply Telegram pings.

Operator action required to receive Telegram alerts: configure four
OpsAlertRules in admin UI - one per new EventType - with appropriate
MinSeverity. The OpsEvents themselves land in the table regardless,
visible in the Ops dashboard.

Tests: 23 new (tier-classifier boundary + dedup-key extraction + JSON
edge cases). Full suite 1098/1098 green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 16:07:06 +02:00
Oliver Kieselbach 0d9ef12f90 chore(backend/security): drop legacy duplicate Intune CA PEMs
intune-root-ca.pem and intune-intermediate-ca.pem are bit-for-bit duplicates
of intune-root-2030.pem and intune-intermediate-mdm-2026.pem (matching SHA1
fingerprints A1:97:D6:... and B7:E4:96:...). The post-9af3de15 loader globs
intune-root-*.pem and intune-intermediate-*.pem and dedupes by thumbprint,
so the duplicates were silently de-duped at load. Remove them now to make
the bundle self-explanatory: one PEM per real CA, name encodes its NotAfter.

Verified by running CertificateValidatorTests (8/8 incl. the real-device-cert
happy-path) which loads the bundle via the same glob path the runtime uses.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 15:59:41 +02:00
Oliver Kieselbach 794800c0cf chore(backend/security): include leaf cert Subject/Issuer/Thumbprint in chain-failure log
Post-deploy of 9af3de15 surfaced 6 chain-validation failures from a single
agent stuck in a 10s retry loop. The current LogWarning only includes the
chain-status string ("unable to get local issuer certificate"), which is
not enough to triage whether the rejected cert is a forge attempt the new
pinning is correctly blocking, or a legit fleet device whose chain we
need to add to the embedded bundle.

Add LeafSubject (GUID-shaped DN for Intune device certs), LeafIssuer (CA
name), and existing Thumbprint to the warning. No PII is leaked - device
certs use opaque GUIDs and the issuer is a public CA DN.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 15:44:15 +02:00
Oliver Kieselbach 9af3de15d4 fix(backend/security): pin client cert chain trust to embedded Intune root (v2)
Re-applies 7e21591b after the chain-validation outage and adds the missing
trust anchors. The first attempt embedded only the 2030 root and the 2026
intermediate; current production device certs chain via the 2026 root +
the 2028 intermediate, so chain.Build() returned "unable to get local
issuer certificate" for every legitimate agent and broke enrolment.

What is different in v2:
- Bundle expanded with both Intune roots (exp 2026-08-12 and 2030-09-15)
  and both currently-issuing MDM Device CA intermediates (exp 2026-05-05
  and 2028-09-15). Real device certs chain via 2028-intermediate -> 2030-
  root today; the 2026 generation is kept for any older device certs
  still in flight.
- Loader now globs intune-root-*.pem and intune-intermediate-*.pem from
  embedded resources and dedupes by thumbprint, so a future Intune root
  rotation is a drop-in PEM with no code change. Legacy generic-named
  files (intune-root-ca.pem, intune-intermediate-ca.pem) are duplicates
  of the new ones and harmlessly de-dup at load.
- Adds a happy-path integration test against a real Intune-issued device
  cert (public-key only, copied from a test VM). This is the regression
  test that would have caught the v1 outage and gates any future bundle
  drift in CI.
- Fixes a pre-existing TZ bug in the expiry check: X509Certificate2's
  NotBefore/NotAfter return Local time, were being compared raw against
  DateTime.UtcNow. Surfaced by the integration test on a CEST dev box;
  unobservable in prod because Linux Flex Consumption runs in UTC.

Trust model is unchanged from v1: X509ChainTrustMode.CustomRootTrust
pinned to the embedded Intune roots, OS trust store ignored, fail-closed
when no roots are loaded. Self-signed-with-Intune-DN bypass remains shut.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 15:28:21 +02:00
Oliver Kieselbach 6658303c0d feat(ops/consent): emit ConsentFlowSuccess to close the consent-flow lifecycle
Pairs with ConsentFlowStarted/Failed in the OpsEvents table so admins can
see whether repeated consent failures (AADSTS, redirect-mismatch, denied)
eventually resolved into a successful grant. New POST endpoint
config/{tenantId}/autopilot-device-validation/consent-success is called
from the post-redirect callback only after consent-status confirms
isConsented=true, so generic status polls do not fire false positives.

Trigger whitelist accepts autopilot, corporate, and device-preparation;
the third value is wired through the type system end-to-end so a future
DevPrep-specific consent flow can use it without a schema bump.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 14:51:54 +02:00
Oliver Kieselbach 5ae7dc40af Revert "fix(backend/security): pin client cert chain trust to embedded Intune root"
This reverts commit 7e21591b55.
2026-05-08 14:35:52 +02:00
Oliver Kieselbach 7e21591b55 fix(backend/security): pin client cert chain trust to embedded Intune root
CertificateValidator previously combined AllowUnknownCertificateAuthority
with a DN-substring scan ("Microsoft Intune"/"MDM Device CA" in
Subject/Issuer of any chain element). A self-signed leaf with that DN
satisfied the substring gate while the unknown-root flag masked the
untrusted-root status -> the X.509 trust gate was bypassable.

Switch to X509ChainTrustMode.CustomRootTrust with the embedded Intune
root in CustomTrustStore and the intermediate in ExtraStore. chain.Build()
now requires a signature link to a pinned root regardless of the OS
trust store, which keeps Linux/Flex Consumption working without the
self-signed bypass. Empty roots -> fail-closed.

Adds 7 unit tests including the self-signed-impersonating-Intune
regression test.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 14:22:20 +02:00
Oliver Kieselbach 4870e09b39 chore(web): drop Inspector deep-link from FailureSnapshotBlock + regen artifacts
Removes the "View at lifecycle anchors" link (and its sessionId prop) from
FailureSnapshotBlock; SessionInfoCard updated to match the new prop shape.
Bumps page-lastmod.generated.ts and the V2 agent buildcounter.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 14:12:50 +02:00
Oliver Kieselbach b6178db8e6 fix(backend/security): enforce body-tenant for non-GA + lift query-tenant guards to middleware
Closes a tenant-boundary write-side hole and removes two manual cross-tenant
checks in favor of catalog-driven middleware enforcement.

SubmitSessionReport + SubmitDiagFilesReport (the violation):
The functions previously fell back to the JWT tenantId only when the body's
TenantId was empty, otherwise trusting the body verbatim. A Tenant Admin of
Tenant-X could POST a body with tenantId=Y and have the report blob, the
SessionReports row, and the audit-log entry all attributed to Tenant-Y
(integrity / non-repudiation issue, no read-side leak).

New behavior: GA-aware. Non-GAs who send a body tenantId that disagrees with
their JWT get a 403 + warning log. GAs may legitimately submit cross-tenant
reports (the UI sends the session's tenantId), but MUST set the body field
explicitly (no silent fallback that would mis-file under the GA's home tenant).

Diagnostics + Progress middleware elevation (defense-in-depth):
diagnostics/download-url and progress/sessions/{sessionId}/events lifted from
TenantScoping.None (in-function guard) to TenantScoping.QueryParam (catalog +
middleware). Same GA bypass semantics — the in-function check was correct but
copy-paste fragile. Functions now read requestCtx.TargetTenantId, which the
middleware has already validated against the JWT.

Test coverage:
CrossTenantAccessTests gains 10 new InlineData rows across the 5 QueryParam
test methods (no-query / same-tenant / cross-non-GA blocked / cross-GA allowed
/ scoping correctness) for both lifted routes. diagnostics/download-url moved
out of JwtImplicitRoutes_NoCrossTenantCheck since it's no longer JWT-implicit.

Verified: dotnet build clean, 1067/1067 backend tests pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 14:12:39 +02:00
Oliver Kieselbach 592788be99 fix(web/security): drop legacy localStorage init in TenantContext
The tenantId useState initializer used to seed itself from localStorage,
opening a small mount-window where a child component could fetch with a
tampered tenantId before the auth effect overrode it from user.tenantId.
No remaining feature reads from this localStorage key (the only
setTenantId call sites are inside TenantContext itself), so the read +
the corresponding write are dead. Initialize empty, clear any stale LS
value once on mount, and rely solely on the JWT-derived user.tenantId.

Backend MUST still derive tenantId from the JWT claim, not from
query/body params - this change is defense-in-depth, not the primary
guarantee.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 13:30:14 +02:00
Oliver Kieselbach f620b884c6 fix(web/security): tighten CSP and harden /go bootstrap error path
- Drop 'unsafe-eval' from script-src CSP — Next.js prod build doesn't
  need it; closes an XSS escalation vector for any future inline payload.
- Cap error messages embedded in the generated OOBE PowerShell script at
  200 chars to prevent a multi-MB upstream message from being inlined
  verbatim into a script that runs as SYSTEM during Windows OOBE.
- Add X-Content-Type-Options: nosniff to the /go error response for
  consistency with the success response.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 13:18:09 +02:00
Oliver Kieselbach 0c24719e16 feat(do-telemetry): add MCC + LinkLocal + symmetric breakdown across all layers
Customer reported "From Peers 100% / Peer breakdown LAN 0 | Group 0 | Internet 0"
mid-download. Root cause: OS collector emitted LAN/Group/Internet/LinkLocal
breakdown only on the final do_telemetry event, not on download_progress events
during the download — so UI rendered 0/0/0 until completion. Empirical check of
917 do_telemetry events / 146 sessions / 13 tenants over 30 days confirmed IME
[DO TEL] log path is dead code (current IME version no longer logs it); OS
cmdlet is the sole live source.

Changes:
- AppPackageState (V2): new fields DoBytesFromLinkLocalPeers,
  DoBytesFromCacheServer, DoCacheHost; UpdateDoTelemetry signature extended
  with optional defaults so callers without the data pass 0/null.
- DeliveryOptimizationCollector: read BytesFromLinkLocalPeers + CacheHost
  from cmdlet output; centralised event payload via BuildDoEventData() so
  download_progress and do_telemetry are field-symmetric.
- ImeLogTracker.HandleDoTelemetry: parses new fields null-safe (older IME
  JSON without them defaults to 0/null, no breakage).
- Persistence: PackageStateData DTO + ToPackageStateData / FromPackageStateData
  mappers carry the new fields across agent restart.
- ImeLogTrackerAdapter.EmitDoTelemetry: emits new fields on do_telemetry,
  including doCacheHost when MCC was used.
- Backend ingest (V1 + V2 paths) + AppInstallSummary model +
  TableStorageService.Metrics Store/Map/preserve-from-existing extended for
  the new columns.
- DoAggregator + GeographicApiModels + GetGeographicMetricsFunction surface
  MCC + LinkLocal at location and per-session level.
- UI DownloadProgress.tsx: new "From Connected Cache" line with cache% and
  hover host; LAN merges LinkLocal in breakdown ("local network"); 3-color
  progress bar (Peers green / MCC purple / HTTP blue) computed by subtracting
  CacheServer from HTTP to avoid double-counting (MCC serves over HTTP).
- UI sessions page: hover title now includes MCC bytes and LAN+LinkLocal sum.

Robustness: every new field defaults to 0 / null / "" on machines or paths
that don't supply them; no existing session display regresses.

Tests: backend 1058/1058, agent V2 1007/1007, web vitest 259/259, tsc clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 11:37:48 +02:00
Oliver Kieselbach c97fb97e54 refactor(mcp): align OAuth state signing key with backend convention
Rename env var STATE_HMAC_KEY → OAuthStateSigningKey and switch decode
from utf-8 to base64, matching the backend's PaginationTokenSigningKey.
Same naming pattern (PascalCase, "...SigningKey" suffix), same encoding
(base64), same fail-loud validation (≥32 bytes after decode).

Bicep param + secret name renamed accordingly (lives on disk only —
infra/ is gitignored). Operator: rotate the secret value during the next
deploy and update the env var name in the Container App.

The fallback-to-random-bytes branch is preserved so single-replica
deployments without explicit key configuration still get a working,
short-lived signature; that is unchanged from the previous commit.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 11:30:45 +02:00
Oliver Kieselbach a4e37cc087 fix(mcp): harden OAuth proxy and access-guard against external review findings
External security review (2026-05-08) flagged 5 issues against the MCP
server's auth surface. All five are addressed in code; one (Bicep param
additions) lives on disk only because infra/ is gitignored — the operator
needs to redeploy with the new mcpPublicUrl + stateHmacKey parameters
documented in the bicep header to put F3 fully into effect.

#1 access-guard cache-key collision (Medium)
  Cache was keyed on UPN only. A forged JWT with a victim's UPN would
  hit a legitimate user's cached `allowed: true` and bypass the backend
  signature check for any MCP-local operation (tools/list, resources/
  list etc.) plus burn the user's rate-limit budget.
  Fix: cache key is now `${upn}:${sha256(token)}`, so distinct tokens
  always require a fresh backend roundtrip. accessCache joins the 5-min
  cleanup tick to evict expired entries (cardinality went up).

#2 /oauth/register DoS surface (Medium)
  Endpoint is unauthenticated per RFC 7591 and the registry was an
  unbounded in-memory Map at 0.5 GiB container budget. Per-IP rate-
  limit was rejected (cloud-proxy egress collisions — Zscaler etc.).
  Fix: route-level express.json({ limit: '8kb' }) registered before
  the global parser; map-size cap at 10_000 entries (~3 MB worst-case)
  → 503 when full; field-level caps on redirect_uris.length and
  client_name. TTL/LRU eviction is intentionally NOT enabled —
  documented as TODO(oauth-map-ttl) because it interacts with the
  manual /mcp reconnect flow.

#3 OAuth base URL trusted X-Forwarded-* headers (Medium)
  Discovery, issuer, WWW-Authenticate, and `${baseUrl}/oauth/callback`
  were all derived from caller-supplied headers. Container Apps Ingress
  appends but does not strip X-Forwarded-Host, so a hostile client could
  spoof the issuer in /.well-known/oauth-authorization-server.
  Fix: startup warning when MCP_PUBLIC_URL is unset. The bicep file
  (gitignored) was also extended with a `mcpPublicUrl` param — operator
  must redeploy with that pinned to the Container App FQDN after the
  first deploy emits it as an output.

#4 PKCE/state hardening (Medium)
  code_challenge was optional and only forwarded conditionally;
  code_verifier was never required at /oauth/token; state was bare
  base64url-JSON without integrity protection.
  Fix: /oauth/authorize now rejects requests without code_challenge
  or with code_challenge_method != S256 (the discovery doc already
  advertised S256-only). /oauth/token requires code_verifier for
  grant_type=authorization_code. state is HMAC-SHA256 signed with
  STATE_HMAC_KEY (env, fallback per-instance random), payload includes
  iat, /oauth/callback rejects tampered or >10-min-old state in
  constant-time compare. Bicep adds a secure `stateHmacKey` param;
  unset is acceptable for single-replica deployments.

#5 Structured 5xx leak in error-handler (Low)
  ApiError.parsed branch flowed all 5xx detail (error / hint /
  exceptionType) through to the MCP client. exceptionType in
  particular leaks CLR class names that have no business reaching a
  downstream caller.
  Fix: status>=500 branch moved before the parsed-detail branch and
  applies generic-message sanitization to structured 5xx too. Only
  correlationId and errorCode survive — those are operational
  handles for an operator to pivot on, not internals.

Tests: src/__tests__/security-guards.test.ts. The pre-existing
"preserves structured 5xx" case is inverted to assert sanitization.
16 new tests cover the cache-key fix, the registry caps, and the
state HMAC roundtrip / tamper / expiry / replay paths. 47/47 green
under `vitest run src/__tests__/security-guards.test.ts`. The
tools-*.test.ts integration suites still require AUTOPILOT_API_TOKEN
to run (pre-existing).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 11:18:53 +02:00
Oliver Kieselbach c0a8f2ed27 fix(agent/v1/sw-inventory): mirror V2 fix — accept AAD/MSA SIDs and emit real registry path
V1 carried the same two bugs as V2: the per-user profile enumeration filter
only matched S-1-5-21-* SIDs, silently skipping the AAD/MSA cloud SIDs
(S-1-12-1-*) that Win10 1903+ uses for AAD-joined users — so on Win11 AAD
boxes the per-user Uninstall hive of the actually-logged-in user was never
read. Additionally, CollectFromKey logged "{source}\{subKeyPath}" which
mashed the telemetry tag (HKU_<RID>) with the relative path, producing a
non-path string like "HKU_1000\S-1-5-21-...\SOFTWARE\..." in debug logs.

Same patch as V2 commit 6c4f7250: extend SID filter to include S-1-12-1-*,
log rootKey.Name (HKEY_USERS / HKEY_LOCAL_MACHINE / ...) as the path prefix
and report the telemetry tag separately as "(source=...)".

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 22:53:33 +02:00
Oliver Kieselbach 6c4f7250b9 fix(agent/v2/sw-inventory): include AAD/MSA SIDs and emit real registry path in logs
- Filter in CollectFromAllUserProfiles now also accepts S-1-12-1-* SIDs (Microsoft Account / Azure AD cloud SIDs on Win10 1903+ AAD-joined devices); previously only S-1-5-21-* matched, so the per-user Uninstall hive of the actually-logged-in AAD user was silently skipped on Win11 AAD-joined machines.
- CollectFromKey log line now uses rootKey.Name (HKEY_LOCAL_MACHINE / HKEY_USERS / ...) as the path prefix and reports the telemetry tag separately as "(source=...)". Removes the misleading "HKU_1000\S-1-5-21-...\SOFTWARE\..." Frankenstein-path that mashed the label with the subkey path.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 22:50:00 +02:00
Oliver Kieselbach cacfee4bcc fix(web/sla): violator link points to /sessions/{id} not /session/{tenant}/{id}
The violator table on /sla linked to /session/${tenantId}/${sessionId},
which 404s — the canonical route is /sessions/[sessionId] (single
segment, plural). Same convention as /diagnosis, /admin and the
inspector. Restores navigation and makes sla_violator_opened actually
fire.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 22:08:45 +02:00
Oliver Kieselbach 4a0e56a731 fix(web): use submitted (not live) tenant filter for session fetch URL
User typed a tenant filter without clicking Submit, then clicked Next →
fetchSessionsBatch read the live tenantIdFilterRef and built a URL with the
typed filter, but the continuation token in state was issued before the typing
under a different filter scope. Backend recomputed fingerprint, mismatched →
400 filter_mismatch.

Track the submitted filter in a separate ref, updated only via
refetch/refetchWith/initial-fetch/global-mode-toggle. The live ref keeps
driving client-side display via effectiveSessions; backend pagination always
queries under the scope its continuation was issued for.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 21:12:13 +02:00
Oliver Kieselbach c6e46e9a0a fis dashboard session fetch 2026-05-07 20:55:35 +02:00
Oliver Kieselbach 25dc67ff69 fix(mcp): security hardening - 7 review findings
H1 OAuth redirect_uri host allowlist (loopback always; hosted clients
   via MCP_ALLOWED_REDIRECT_HOSTS, default Anthropic/OpenAI/Google).
   Closes auth-code-leak through unvalidated /oauth/callback.
M1 followNextLink rejects continuation paths that don't equal the
   tool's own basePath - closes cross-tool path substitution.
M2 strict UUID schema on every sessionId input - blocks WHATWG-URL
   path-normalization attacks via /api/sessions/{id}.
M3 rate-limit moved after access-check (Step 1) - forged JWTs cannot
   drain a real user's per-minute budget.
L1 get_rule_stats uses pickGlobalOrTenantPath like every other tool.
L3 5xx response bodies no longer relayed to the caller; structured
   4xx errors keep their detail.
L4 hardcoded CLIENT_ID fallback removed - module throws on boot if
   AUTOPILOT_ENTRA_CLIENT_ID is unset.

Adds security-guards.test.ts (32 unit tests) covering all guards.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 20:22:17 +02:00
Oliver Kieselbach 05ce8cac9a fix(mcp,backend): close two gaps from MCP routing PR — get_ops_events tenantId + global metrics summary tenantId filter
Re-test post-deploy of #60 surfaced two minor follow-ups:

1. get_ops_events MCP tool: backend already accepts tenantId filter
   (since C2), but the MCP tool schema didn't expose tenantId so Zod
   silently dropped any caller-passed value. Adds tenantId to the input
   schema and forwards it in the query. 2 LOC.

2. /api/global/metrics/summary: the global handler was hardcoded to
   pass null (all tenants) and ignored ?tenantId=X. Adds the same
   optional filter pattern used elsewhere — null when absent, scoped
   when present. The tenant-scoped /api/metrics/summary route is
   unchanged (JWT-tid still authoritative).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 17:09:43 +02:00
Oliver Kieselbach 688162eb73 fix(web): rename Email to "Communication Email (optional but recommended)" + center Submit Logs card
Submit Logs (tenant settings) and Report Session modal both relabel the
email field so users understand it's the channel we may reply on; the
Submit Logs card also gains mx-auto so it sits centered in the wide
tenant-settings layout instead of left-aligned.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 16:48:08 +02:00
Oliver Kieselbach b692baa5d9 feat(mcp): get_session_summary lean — cap key events + drop heavy payloads
The summary used to embed every key event's full data/dataJson payload —
one busy session could push the response well past 80 KB and trip the
response cap. The point of the summary is triage, not raw payload
delivery; users wanting full event data already have get_session_events.

Now:
  * key events sorted by relevance (Errors > phase transitions > Warnings
    > others), capped at 50 entries; full timeline still drives stats
  * the displayed slice is re-sorted chronologically for readability
  * heavy `details` field dropped — was the main bloat source
  * stats.keyEventsTotal + keyEventsTruncated exposed so callers know if
    more events were filtered out

Description updated to set expectations + point at get_session_events for
full payloads.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 16:41:22 +02:00
Oliver Kieselbach b4529d2c71 feat(mcp): get_resource tool + description tightening
Workaround for clients (e.g. Claude Code's HTTP-MCP bridge in stateless
mode) that don't expose `resources/list` reliably. Verified with a paired
in-memory client that the server itself advertises `capabilities.resources`
and answers `resources/list` correctly — the breakage is on the client
side. Tools work everywhere, so a tool-call gateway is the pragmatic fix.

Resources now share a single source-of-truth (`resource-catalog.ts`) used
by both the MCP-protocol resource handlers and the new `get_resource(name)`
tool. Tool descriptions previously said "see the device_properties resource"
— updated to "call get_resource(name=...)" so the hint actually leads
somewhere usable.

Also tightens get_rule_stats description: the default window × number of
rules × per-day trend can easily exceed the response cap, so the doc now
recommends passing a tighter date range or ruleType filter for routine
queries.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 16:40:01 +02:00
Oliver Kieselbach 629c12eb55 feat(mcp): role-aware routing — GA → /api/global/*, tenant-admin → /api/*
Fixes the silent confusion where a Global Admin who passed `tenantId=X`
got routed to the tenant-scoped endpoint, which used the GA's own JWT
tenant (home), not the requested one. The user saw their home tenant's
data instead of X — no error, just wrong data.

Caller role flows from /api/auth/mcp (now returns isGlobalAdmin) into
an AsyncLocalStorage caller context (token + isGlobalAdmin). Tools pick
the route via a small helper:

  pickGlobalOrTenantPath('/api/global/X', '/api/X')

  GA           → /api/global/X (cross-tenant; tenantId is a filter param)
  Tenant-Admin → /api/X (tenant-scoped; JWT-tid authoritative, isolation strict)

Refactors 13 routing sites across tools/{sessions,admin,search}.ts. Each
backend pair was verified to exist (both `/api/X` and `/api/global/X`
have a Function defined).

Also drops the MCP-side filter pass in get_session_events — server-side
filtering was added in the previous commit so count and nextLink are
now coherent.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 16:37:27 +02:00
Oliver Kieselbach 3116a0ecc9 feat(backend,web): geographic-sessions default-lean (?full=1 opt-out)
The full LocationSessionRow shape is ~40 fields per session — for
locations with dozens of sessions the response easily blew past 100KB
and tripped MCP token caps. Most fields aren't needed for the typical
"which sessions enrolled from here" triage flow.

Default is now a 17-field lean projection (sessionId, device basics,
status/duration, geo, app/DO summary). UI keeps the full shape via
?full=1 — single-line addition in lib/api.ts.

Also adds an optional tenantId filter on /api/global/metrics/geographic/
sessions so a GA can scope a single tenant's sessions for a location
without leaving the global endpoint.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 16:31:39 +02:00
Oliver Kieselbach 84a1aa14cb feat(backend): server-side filters for /api/sessions/{id}/events
Push eventType/severity/source filters from MCP-tool layer down into the
Function so count and nextLink stay coherent. Previously filters lived
client-side: backend returned the full page, MCP filtered → could
produce count: 0 with nextLink set, which read as a bug ("nextLink is
emitted even though there's nothing left").

With server-side filters:
- count reflects post-filter items in this page
- nextLink is still gated on NextRawToken (more raw rows ahead in this
  session — filter may or may not match further down)
- filter values echo back onto nextLink so callers can follow pagination
  without having to re-supply them

SessionEventsPagination.BuildNextLink learns an optional `extras` arg so
arbitrary query params survive the round-trip.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 16:27:49 +02:00
Oliver Kieselbach b9997809ed feat(backend): tenantId filter on global audit/logs + ops-events
Both endpoints already supported optional date-window + pagination but
ignored a tenantId query param. With the upcoming MCP role-aware routing
GA needs /api/global/* endpoints to filter to a single tenant while
keeping cross-tenant capability when the param is absent.

- audit/logs: routes to existing tenant-scoped IMaintenanceRepository
  methods when tenantId is set (cheap PK lookup); fingerprint scope
  widened to "audit:global:tenant:<id>" so cross-token reuse fails.
- ops-events: OpsEvents are partitioned by category, not tenant — the
  filter is applied post-fetch. Pages may report fewer items than
  pageSize when the filter is narrow.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 16:22:52 +02:00
Oliver Kieselbach fae4d5eb2f feat(backend): /api/auth/mcp returns isGlobalAdmin
The MCP server needs to know the caller's GA status to make routing
decisions (GA → /api/global/* with tenantId filter; non-GA → /api/*
JWT-bound). Previously the access-check response only carried
allowed/reason/accessGrant — the MCP layer had no way to detect role.

McpUserService now resolves IsGlobalAdmin unconditionally (it's needed
for both whitelist and AllMembers paths), and the result rides through
on McpAccessCheckResult.IsGlobalAdmin. Existing factory signature stays
backward-compatible via default-param.

Prep for the upcoming MCP role-aware routing refactor.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 16:22:40 +02:00
Oliver Kieselbach 3fe519d839 Merge branch 'main' of https://github.com/okieselbach/Autopilot-Monitor 2026-05-07 16:18:37 +02:00
Oliver Kieselbach 5292cd2dfe fix submit diag sidebar entry 2026-05-07 16:17:52 +02:00
Oliver Kieselbach 089c0123e6 feat(reports): add Submit Logs page for session-less diag-files reports
Tenant Admins can now ship log files, JSON state snapshots (.log/.txt/.zip/
.json/.jsonl/.ndjson), and screenshots to the Autopilot Monitor team from
/settings/tenant/support without needing a session context — no more
OneDrive round-trips for ad-hoc client-log requests.

Backend
- New POST /api/diag-files-reports (TenantAdminOrGA) -> SessionReports table
  + session-reports container, distinguished by ReportType="diagFiles".
- ReportType discriminator added to SessionReportMetadata + Store/Map
  (legacy rows default to "session").
- Telegram + Global-Admin bell notifications mirror the session-report flow.

Frontend
- New "Submit Logs" tenant settings tab with comment / email / log+state
  files (5 MB cap) / screenshot pickers; multi-file auto-zip via fflate.
- Admin SessionReportsSection now shows a Type column (Session / Diag Files)
  and hides Session ID in the detail modal for diag-files rows.

Verification: dotnet build clean, 1058 backend xUnit tests pass, tsc
--noEmit clean, 259 vitest tests pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 16:02:55 +02:00
Oliver Kieselbach 93152d86ec fix whiteglove split 2026-05-07 15:02:52 +02:00
Oliver Kieselbach d4d828d8c6 feat(web): in-flight request collapser (dedupedAuthFetch) + 2 callsites
Adds a drop-in wrapper around authenticatedFetch that collapses concurrent
GET/HEAD requests to the same URL into a single underlying network round-
trip. Each caller receives an independently readable Response clone.

This is a request-collapser, NOT a cache. Entries live only as long as
the underlying Promise is unresolved; once it settles, the entry is
removed on the next microtask. No TTL, no eviction, no staleness — by
design, since SignalR push-truth would race against any pull-cache. Calls
that merely happen to hit the same URL after a response has arrived go
fresh.

Mutations (POST/PUT/PATCH/DELETE) bypass the collapser unconditionally
so write semantics are unchanged.

Migrated two known-duplicating callsites:
- useTenantSecurityConfig (dashboard) — feature-flags GET that re-fired
  on the user=null → UserInfo transition.
- useSessionTenantConfig (session detail) — feature-flags GET that
  could overlap with other consumers on the same render burst.

Belt-and-suspenders: the prior dep-stabilization + !user guard on
useTenantSecurityConfig stays in place; the collapser is defense-in-
depth so future regressions of the same shape are caught generically.

8 new vitest cases cover: concurrent collapse, independent body reads,
no caching across microtask boundaries, different URLs not collapsed,
mutations bypass, rejection propagation + slot release, HEAD collapsing,
and GET vs HEAD keyed separately. 257/257 web tests, tsc clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 14:17:59 +02:00
Oliver Kieselbach 22427e24c5 perf(web,backend): finish off the duplicate-fetch + response-header fixes
Post-deploy re-validation in the browser surfaced two issues that the
previous commit did not actually solve:

- Timing-Allow-Origin was still missing on every API response (curl
  confirmed) and X-Correlation-ID was missing too — a preexisting bug.
  Both middlewares used httpContext.Response.OnStarting() to attach the
  header, but that callback is not reliably triggered in the .NET 8
  isolated worker: the host bridges the worker's response to the wire,
  so OnStarting fires on a shadow object that never reaches the client.
  UserRateLimitMiddleware already writes X-RateLimit-* directly on
  Response.Headers before calling next(), and those headers do come
  through in production. Switching both header-emitting middlewares
  to that pattern.

- /dashboard kept firing /api/config/<t>/feature-flags twice. The
  earlier dep-stabilization on useTenantSecurityConfig was incomplete:
  the effect fires once on initial render with user=null (deps
  [tenantId, undefined, undefined, undefined]) and once after AuthContext
  resolves user (deps [tenantId, bool, bool, role]) — two distinct dep
  tuples, two fetches. The user=null path also bypassed the role gate
  (the guard was `if (user && ...) return`). Adding `!user` to the
  early-return at the top of the effect collapses to one fetch and
  also drops the would-be 401 we were producing during the loading
  window.

Backend tests 1058/1058, web vitest 249/249, tsc clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 14:01:09 +02:00
Oliver Kieselbach 0fc81f99ed perf(web,backend): cut dashboard load time + fix duplicate fetches
Browser RUM on portal/dashboard surfaced ~1.7s time-to-data driven by
sequential backend reads, two stray duplicate fetches, and a 2s SignalR
defer. Five focused changes:

- AuthContext: store the auth/me prefetch as a Promise instead of its
  resolved value so fetchUserInfo awaits the in-flight request rather
  than racing a second fetch when the prefetch hasn't settled yet.
- useTenantSecurityConfig: depend on user.isTenantAdmin/isGlobalAdmin/
  role primitives instead of the whole user object so the effect stops
  refiring when AuthContext swaps prefetched user for fresh user (the
  duplicate /config/<t>/feature-flags call observed on /dashboard).
- SignalRContext: drop the hardcoded 2000ms setTimeout before
  startConnection. The original "avoid competing with critical API
  calls" rationale doesn't apply across origins (web is HTTP/2, API is
  a different host); the delay only pushed live updates ~2s into the
  load for no benefit.
- FeedbackFunction: keep the kill-switch check sequential, then run the
  remaining four Table reads (role, tenant config, sessions existence,
  feedback cooldown) in parallel via Task.WhenAll. Endpoint dropped
  from ~1285ms (6 sequential reads on Flex Consumption) toward the
  slowest single read.
- TimingAllowOriginMiddleware: emit Timing-Allow-Origin: * on every
  function response so the portal's Resource Timing API can read API
  payload sizes (transferSize / nextHopProtocol) for our own RUM —
  cross-origin entries currently report 0/unknown.

Backend tests 1058/1058, Web tsc clean, vitest 249/249.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 13:37:52 +02:00
Oliver Kieselbach b61e327752 added full width button 2026-05-07 12:19:11 +02:00
Oliver Kieselbach b91b6c6d52 fix(backend): push AgentVersion(Prefix) filter server-side in search_sessions scan
The paged scan path (`SearchSessionsByScanPageAsync`) routed AgentVersion +
AgentVersionPrefix through `MatchesScanClientFilters` — applied AFTER the
SessionsIndex page was already fetched. For sparse subsets like "all V2
enrollments" (~1% of sessions) this meant the LLM had to follow 10-30 nextLink
roundtrips of mostly count:0 pages while the index walked past the dense
non-V2 majority.

Move both filters into `BuildSearchScanFilter` as native OData clauses, same
trick `DeviceName` and `OsBuild` already use:

  AgentVersion eq 'X'                                       (exact)
  AgentVersion ge 'X' and AgentVersion lt 'X~'              (prefix)

Drop the now-redundant client-side check in `MatchesScanClientFilters`. The
device-snapshot path (`SearchSessionsByDeviceSnapshotPageAsync`) keeps the
client-side filter in `ApplyBasicFilters` because DeviceSnapshot doesn't carry
AgentVersion as a column.

Result: search_sessions(agentVersionPrefix="2.0.") becomes a single-call
operation for typical tenants instead of a roundtrip marathon.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 12:01:31 +02:00