Commit Graph

1854 Commits

Author SHA1 Message Date
Oliver Kieselbach e9c5bfbafc fix(security): backend-review M1-M3 — kill-switch + MCP cache TTL + SignalR broadcast gate
M1: hoist the McpUsers Disabled-row kill-switch above the policy switch so an
explicitly disabled account is denied MCP access under EVERY policy and grant
path (AllMembers, platform roles, delegated auto-grant) — previously inert
outside the WhitelistOnly branch.

M2: drop the MCP access-guard verdict cache from 5 min to 60s. The verdict
carries the kill-switch and the delegated tenant set; 5 min stretched the
backend's 30s revocation target to minutes for MCP callers.

M3: gate same-tenant joins of the plain tenant-{tid} SignalR broadcast group
on a member role (new pure SignalRGroupHelper.IsTenantBroadcastJoinDenied) —
roleless authenticated users (Progress Portal) could passively stream
MemberRead-tier live telemetry they cannot fetch over REST. Companions:
MarkSessionSucceeded/Failed additionally push "newevents" to the per-session
group so a roleless progress watcher still sees admin-marked completion, and
the Progress Portal no longer joins the tenant group (session group covers
all its signals).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 15:44:36 +02:00
Oliver Kieselbach 34582c0f0e feat(kill-switch): deliver kill on config channel + KillSignalDelivered ops event
Kill/Block rules were only served on the telemetry response, which cannot
reach an agent whose upload loop is paused (indefinite block pins the drain
until DateTime.MaxValue), whose spool is empty, or whose binary is kill-blind.
Config is fetched at every process start (boot via Scheduled Task), making it
the reliable control channel - no heartbeat added by design (48h emergency
break + 6h max lifetime bound the remaining cases).

Backend (effective on deploy):
- KillSwitchEvaluator: shared device+version check for both channels; ingest
  wire response and check order unchanged (device first, short-circuits)
- Every served Kill emits ops event KillSignalDelivered (Security/Warning),
  throttled 24h per tenant+serial+pattern; Blocks stay event-free
- GET agent/config (incl. bootstrap wrapper) now returns DeviceBlocked/
  DeviceKillSignal/UnblockAt alongside the full config; old binaries ignore
  the new fields
- Web: KillSignalDelivered dual-registered in OPS_EVENT_TYPES

Agent V2 (effective with next agent release):
- CheckConfigKillSignal guard after the Phase-4 fetch: forced self-destruct
  (overrides SelfDestructOnComplete), enrollment-complete marker, session
  cleared, exit before mTLS/registration
- Kill honoured ONLY from a live fetch: config disk cache strips the flags on
  write and read (UnrestrictedMode pattern), guard re-checks LastFetchOutcome

Tests: 9 KillSwitchEvaluatorTests + 7 ProgramConfigKillSignalTests;
Functions 2557/2557, Agent V2 1578/1578, web tsc clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 13:49:32 +02:00
Oliver Kieselbach d68d77975b v2 banner removal and added tipoftheday 2026-07-03 12:51:13 +02:00
Oliver Kieselbach ac72ebb420 fix(backend): delegated config/all must not silently drop tenants on read errors
Codex Medium on 43a87ff7: the delegated subset was built from
TryGetConfigurationAsync, which maps ANY storage failure to exists=false —
transient table throttling made managed tenants vanish from a 200 response,
whereas the replaced scan path rethrew and returned 500.

- New TenantConfigurationService.GetConfigurationIfExistsAsync: strict
  point-read (cached, non-creating, null on 404) that PROPAGATES storage
  errors; TryGetConfigurationAsync now delegates to it and keeps its
  fail-closed mapping for agent security gates.
- Delegated config/all reads via the strict method → a read failure fails
  the request (500) instead of shrinking the subset.
- Adjacent regressions from the same hunk: AllowedTenantIds deduped
  case-insensitively (old HashSet-filter semantics), and ids missed by the
  exact-case point-read get one scan+filter rescue pass — config PartitionKey
  casing is not guaranteed (agent ingest auto-creates rows verbatim; same
  caveat the delegated sessions fan-out handles case-insensitively).

Functions suite 2548/2548 (9 new tests).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 12:00:16 +02:00
Oliver Kieselbach bae97c2486 Merge pull request #83 from okieselbach/fix/v2-delta-review-findings
fix(agent-v2): remediate all 28 findings from the 2026-07-02 V2 delta review
2026-07-03 11:36:51 +02:00
Oliver Kieselbach 6f8ce556b7 fix(mcp-security): rate-limit /oauth/* and allowlist grant_type (review R-3)
The unauthenticated /oauth/* routes carried no throttle (accessGuard covers
/mcp only), making /oauth/token an anonymous amplifier: every POST triggers
an outbound Entra call with the app's client_secret attached, so a flood
would get the app registration itself throttled by Entra — an org-wide auth
DoS requiring zero credentials.

- Per-source-IP sliding-window throttles as per-route middleware inside
  createOAuthRouter: 20/min on /oauth/token, 60/min on register/authorize/
  callback (MCP_OAUTH_TOKEN_RATE_LIMIT_PER_MINUTE / MCP_OAUTH_RATE_LIMIT_
  PER_MINUTE). Own bucket map keyed `${kind}:${ip}` so OAuth floods never
  burn the /mcp pre-auth budget and vice versa; pruned by the shared reaper
  and capped by MAX_RATE_BUCKET_ENTRIES. 429s reuse respondRateLimited, so
  the existing `[mcp-auth] 429 kind=` log/KQL pattern covers the new kinds.
  .well-known discovery docs stay unthrottled (static JSON, no outbound work).
- grant_type allowlist on /oauth/token: missing -> invalid_request, anything
  but authorization_code/refresh_token -> unsupported_grant_type, rejected
  before the client secret leaves the building. Policy now lives in the
  proxy instead of relying on Entra-side accidents (pinned scope, uncopied
  fields) to reject client_credentials/ROPC/device-code.
- Tests: oauth-rate-limit.test.ts (tiny pinned budgets, bucket independence
  in both directions) + grant_type allowlist block in oauth-register.test.ts.
  Env pins in security-guards.test.ts hoisted above the first oauth.js
  import — oauth.ts now (transitively) loads access-guard.ts at import time.

MCP server v1.4.5 -> 1.4.6. vitest 345 passed / 41 skipped, tsc clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 11:27:42 +02:00
Oliver Kieselbach 9582ad8d8b fix(agent-v2): termination, recovery and lifecycle LOWs (L2-L4, L6, L7, L9, L16, L18, L19, L21)
Delta-review 2026-07-02 findings:
- L2: HelloTracker wait-timer re-arm races Stop()`s lock-free timer dispose —
  an ObjectDisposedException on a threadpool timer thread kills the process
  on net48. Swallowed with a debug log (shutdown is winning anyway).
- L3: MaybeEmitAgentLateStart gets the same _post==null guard as its siblings.
- L4: agent_late_start / lowObservationCoverage mixed clock epochs (boot =
  now-minus-uptime at termination vs wall-clock agentStartUtc). The handler
  now captures a (tick, wall) anchor pair at construction and derives boot in
  that epoch, so NTP corrections between start and termination no longer skew
  bootToStartSeconds.
- L6: terminal starved-app sweep TOCTOU — snapshot-then-emit could race a
  concurrent live app_install_starved and double-report. Replaced the
  read-model snapshot property with an atomic TryClaimStarvedUserEspAppReport
  against the live path`s dedupe set (tracker → host → surfaces → read model).
- L7: a quarantine marker whose delete persistently fails (AV/ACL) re-wiped
  the FRESH run`s state on every restart. After a successful quarantine the
  stuck marker is neutralized with a processed-sentinel; the next start only
  retries the delete.
- L9: DeviceInfoHost is created (and thus subscribes) BEFORE EspAndHelloHost,
  whose Start can immediately post EspPhaseChanged(DeviceSetup) — the
  enrollment-start re-collect trigger was missed on sessions failing before
  the end trigger.
- L16: UserEspKeepAwakeHost no longer holds _gate across the blocking
  controller Engage (Idle→Engaging→Engaged claim; a racing Release wins and
  the engage path rolls the OS hold back).
- L18: Office preinstalled short-circuit — accepted-risk comment documenting
  the silent-miss window (never a false office_install_failed).
- L19: durationSeconds carries a durationBasis discriminator (script_runtime
  vs cycle_including_reporting_latency) so platform and remediation durations
  are never compared as equals.
- L21: stale ShellCoreTrackerAdapter comment reference removed (ARCH-F7).

(L8 was fixed with M1 in the parked-tripwire commit; L11 is moot since M6
made the prefetch fingerprint static.)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 11:19:11 +02:00
Oliver Kieselbach c23dc9a329 fix(agent-v2): console-bypass watcher hardening (L12, L13, L14, L15, L20)
Delta-review 2026-07-02 findings:
- L12: `cmd /k` leaves a fully interactive shell after running its command —
  it was classified as a scripted invocation and silently ignored (deliberate
  false-negative seam, e.g. a technician-planted `cmd /k whoami`). Now
  surfaced as low-confidence `interactive_console_with_command`; only /c
  (run-and-exit) is ignored.
- L13: the WMI trace arm + startup probe ran synchronously in the
  orchestrator`s sequential host-start loop; a WinMgmt hang (documented OOBE
  failure mode) delayed every later host. Both now run on a background
  thread, with a dispose-race guard around the watcher arm.
- L14: the desktop-arrival callback was the ONLY stop path — if the DAD never
  validated a real-user owner, the watcher flagged ordinary post-desktop user
  consoles for the agent`s whole remaining lifetime. Fallback: the host also
  stops on a DesktopArrived or HelloResolved signal on the decision rail.
- L15: wtsErrorCount counted the routine "session has no logged-on user yet"
  OOBE case as an error; ProcessOwnerLookup.ViaWts now reports real API
  failures separately and only those are counted.
- L20: WatchedProcessNames is now actually the single seam — WQL trace
  filter, startup probe and the emitted processName are all built from it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 11:03:47 +02:00
Oliver Kieselbach 3da26ee8d4 fix(agent-v2): two-phase StartupEventGate, restart-safe disk_space_low, prefetch fixes (M3-M6, L17)
Delta-review 2026-07-02 findings:
- M4: StartupEventGate persisted the new fingerprint INSIDE ShouldEmit, before
  the caller emitted — a process death between the save and the spool append
  suppressed that event for the rest of the enrollment (permanent for static
  payloads like tpm_info / console_prefetch_detected). ShouldEmit now claims
  in-memory only; the new MarkEmitted(key) commits to disk AFTER the emission,
  and SaveSafe writes the committed view only, so other keys` uncommitted
  claims never ride along. All five consumers updated.
- M3: disk_space_low hysteresis latch was an in-memory bool → the Warning
  (ImmediateUpload) re-fired after every stall restart on a disk-starved
  device. Latch is now seeded from + persisted via the StartupEventGate (new
  read-only HasFingerprint peek); the re-arm transition persists too.
- M5: ConsolePrefetchScanner compared timestamps from two clock epochs
  (.pf last-write vs now-minus-uptime); an OOBE NTP correction could swallow a
  real Shift+F10 hit. 10-min skew tolerance on the after-boot test.
- M6: prefetch Warning is once-per-enrollment now — the old fingerprint
  contained prefetchLastRunUtc, which every ESP `cmd /c` installer wrapper
  refreshes, so multi-reboot sessions re-warned per restart. (Also moots L11.)
- L17: a geo re-lookup that only feeds the pending timezone retry no longer
  emits a device_location failure after an earlier success; outbound_ip got
  its own latch instead of riding the device_location one.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 10:56:45 +02:00
Oliver Kieselbach 4af0adb3ed fix(decision-core): parked-tripwire dwell, IME evidence upgrade rule, advisory anchor fix (M1, M2, L5, L8)
Delta-review 2026-07-02 findings:
- M1: session_parked_without_deadline evaluated the parked predicate on the
  very step that produced the state, so the healthy Classic window
  (HelloResolved -> AwaitingDesktop, no deadline, desktop seconds away) fired
  a spurious Warning AND consumed the one-shot, masking a real variant-4 park
  later in the run. The tripwire now only arms a 10-min dwell timer; it fires
  (and consumes the one-shot) only if the session is still parked when the
  dwell elapses. Steps that clear the parked condition cancel the pending
  arm; parked pass-throughs do not re-base it. Processor is now IDisposable
  (timer), disposed by the orchestrator.
- L8 (rides along): the tripwire post moved off the ingress worker onto the
  timer thread — a full channel can no longer self-deadlock on it.
- M2: ImeUserSessionCompletedUtc was set-once, so an early defaultuser0
  completion permanently failed the AdvisoryCompletion conjunction and
  resolved genuinely-completed sessions as Failed. New rule: first
  post-AccountSetup observation wins (one upgrade of a pre-anchor stamp,
  then frozen). Pure function of (state, signal) — replay-safe.
- L5: HandleAdvisoryCompletionDeadlineFired`s esp-exit anchor is presence-only
  now; the old >= AccountSetupEntered comparison rejected fires whose arming
  exit carried a backfilled source timestamp, re-opening the dead-end.
  deadlineStillArmed remains the stray-fire guard.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 10:45:22 +02:00
Oliver Kieselbach 1eb13e30b2 fix(agent-v2): persist platform-script dedup markers across restarts (H1, L1, L10)
Delta-review 2026-07-02 findings:
- H1: shutdown force-flush emits an agentexecutor_fallback completion, but the
  emitted-marker set and pending buffer were memory-only. After a restart the
  tracker parsed IME`s later-written PS-SCRIPT-RESULT line fresh and emitted a
  duplicate script_completed. Persist both in ImeTrackerStateData (null-safe
  for old state files); pending scripts continue their grace window after a
  restart instead of being dropped.
- L1: stale-result guard — a PS-SCRIPT-RESULT older than the pending slot`s
  StartedAtUtc belongs to a previous run of the same policy; drop it instead
  of merging run 1`s result into run 2`s slot (which also swallowed run 2`s
  genuine result via the emitted-marker).
- L10: script_timeout_suspected one-shot dedup moved from the adapter`s
  in-memory set to tracker state (TryClaimScriptTimeoutSuspected), so the
  Warning stays one-shot across stall restarts.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 10:33:47 +02:00
Oliver Kieselbach aa9adfbac0 refactor: move diag-files report submit to /api/diagnostics/files
The old route /api/diag-files-reports was not covered by any
clientCertExclusionPaths prefix on the Function App, so the platform
cert gate (clientCertMode=Required) prompted portal users for a client
certificate. /api/diagnostics is already excluded, and the endpoint
submits diagnostic files, so it moves under that prefix instead of
growing the portal-only exclusion list.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 10:14:16 +02:00
Oliver Kieselbach 30df1f35d3 update Hardware Whitelist descriptions 2026-07-02 15:14:50 +02:00
Oliver Kieselbach 262e996c88 fix(web): stack qualifier pills below the status badge in the session table
Hybrid / Self-Deploying / Blocked pills rendered inline next to the status
badge and widened the status column noticeably. Column layout (flex-col)
stacks them underneath instead, keeping the column narrow regardless of
how many qualifiers a session carries.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 15:05:18 +02:00
Oliver Kieselbach b606aee9fa Merge pull request #82 from okieselbach/feat/selfdeploying-kiosk-completion
feat: complete self-deploying/kiosk sessions (OobeConfig seed + AccountSetup false-positive waiver)
2026-07-02 13:06:05 +02:00
Oliver Kieselbach ecc7844996 feat(web): show Self-Deploying badge and enrollment-type label for kiosk profiles
Renders the agent-detected IsSelfDeployingProfile flag: teal pill in the
dashboard session table (next to the Hybrid pill) and "Autopilot
(Self-Deploying)" in the session info card enrollment-type label. The
OobeConfigModal client-side bit decode stays as an independent detail view.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 13:01:01 +02:00
Oliver Kieselbach e453b96395 feat(backend): persist and expose IsSelfDeployingProfile session flag
Persists the agent-sent self-deploying/kiosk profile marker (template:
IsHybridJoin, write-once): Sessions column + SessionsIndex full-mirror
projection + SessionSummary mapping. Re-registration preserve is a
sticky-true OR - a boot with an unreadable Autopilot policy cache must
not pin the flag to false once it was observed true.

Filterable end-to-end: SessionSearchFilter + continuation-token
fingerprint + search/raw query params + index/basic scan filters, plus
MCP search_sessions / query_raw_sessions inputs and the get_session
overview field.

Display/analytics only - the completion fix itself ships in the agent
(DecisionCore gates on the EnrollmentFactsObserved seed, not this flag).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 12:59:30 +02:00
Oliver Kieselbach eb132b11fd feat(agent): complete self-deploying/kiosk sessions despite IME AccountSetup false positive
Kiosk devices (self-deploying profile) never completed: the IME logs an
AccountSetup phase line for the kioskUser0 autologon session although user
ESP never runs, which cancelled/blocked the DeviceOnlyEspDetection deadline
- the sole SelfDeploying terminal path - and the session idled to the 5h
backend timeout (reference session 320b3bf7; 436 false Failed platform-wide,
90% of all self-deploying failures).

Platform sweep (8983 sessions / 63 tenants): CloudAssignedOobeConfig bits
0x20|0x40 are exclusive to self-deploying/kiosk/shared profiles (0 of 6785
user-driven sessions carry them, bits always co-occur). The fix seeds
Mode=SelfDeploying@High from that registry marker via EnrollmentFactsObserved
and waives the AccountSetup veto at the four existing decision points
(DSPC short-circuit, deadline race guard B, EspPhaseChanged cancel,
RealmJoin deferred-release re-check) - only when the seeded profile holds
AND user ESP made no real progress. Bits absent = byte-identical behaviour.

Also registers IsSelfDeployingProfile on SessionRegistration (display
metadata; backend persistence follows separately).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 12:50:02 +02:00
Oliver Kieselbach 0f82577606 fix(backend): address review mediums M6-M9 (orphan cleanup, retention starvation, ingest observability, counter idempotency)
M6: DeleteByFilterInBatchesAsync is fail-loud and counts only confirmed
batch submits; the orphan cleanup keeps the EventSessionIndex entry when
the event delete fails, so surviving rows stay discoverable and retry.

M7: GetSessionsOlderThanAsync gains excludeInFlightDeletions - sessions
locked in deletion states no longer occupy the capped read head, so a
stuck head (Poisoned / stranded Queued) cannot starve the retention
fanout tail. Warnings surface a fully-locked backlog and zero-progress
runs.

M8: ingest logs a per-batch warning with per-kind counts and the first
TelemetryItemId when unparseable telemetry items are dropped (agent
clears its spool on success, so drops were silent data loss).

M9: ReconcileSessionRebootCountAsync -> ReconcileSessionCountersAsync;
one partition scan reconciles EventCount and RebootCount at terminal
transitions, self-correcting at-least-once retry double-counts. Script
counters stay increment-only with the drift documented.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 12:00:39 +02:00
Oliver Kieselbach ebbe6a4204 fix(maintenance): do not classify sessions as orphans on transient storage errors
GetEntityIfExistsAsync never throws on 404 (handled via HasValue), so the
RequestFailedException catch in GetOrphanedEventSessionsAsync fired only on
transient errors (429/500/503) and marked live sessions as orphans, causing
their events to be hard-deleted. Log a warning and skip the entry instead;
it will be re-checked on the next maintenance run.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 11:29:10 +02:00
Oliver Kieselbach e6e9ed0d37 Merge pull request #81 from okieselbach/chore/remove-v1-agent
chore: remove legacy V1 agent and its backend ingest path
2026-07-02 09:05:22 +02:00
Oliver Kieselbach 9f7b070e80 chore: remove legacy V1 agent and its backend ingest path
The V1 agent line is retired: no new V1 sessions since 2026-05-09, only
orphaned straggler traffic remained on /api/agent/ingest (last seen
2026-06-29, events for long-cleaned-up sessions). Bootstrap/Intune/CI
have deployed V2-only since the v2 cutover.

Agent:
- Delete AutopilotMonitor.Agent, .Agent.Core, .Agent.Core.Tests
  (projects + sln entries). V2 trio, SummaryDialog, DecisionCore.Tests
  and V2Isolation.targets stay.

Backend:
- Relocate the shared statics/DTOs V2 reused from the V1 partials
  (StampServerFields, SanitizeEventTimestamps, IsMaxLifetimeAgentShutdown,
  RecalculateAppDurations, EventClassification, AppInstallAggregationState)
  into EventIngestProcessor; IngestEventsOutput moves next to
  IngestTelemetryFunction (same namespace, zero ripple).
- Delete IngestEventsFunction (route agent/ingest), BootstrapIngestEvents
  (route bootstrap/ingest), NdjsonParser and their DI registration.
  EventDataNormalizer stays (used by TelemetryPayloadParser).
- Drop both routes from EndpointAccessPolicyCatalog and
  RequestTelemetryMiddleware.TenantHeaderTrustedPaths.
- Retire the V1 update-hash oracle: LatestAgentV1*/LatestBootstrapV1*
  fields + GetAgentLine case 1 (default arm returns empty hashes, so
  stragglers gracefully skip the integrity check; ParseAgentMajor keeps
  its default of 1 on purpose — defaulting to 2 would feed V2 hashes to
  V1 binaries). TableConfigRepository Store+Map updated in lockstep.
- Remove ApiEndpoints.IngestEvents/.BootstrapIngestEvents constants.

Tests: NdjsonParserTests deleted; timestamp/critical-path/max-lifetime
tests repointed to EventIngestProcessor; the two NDJSON pipeline tests
removed (coverage retained by direct-list siblings); catalog/no-store
InlineData rows updated.

Docs: architecture.md (endpoint tables, ingest flow diagram, solution
layout, test matrix), web SectionAgentInternals endpoint tables,
build.ps1 help text.

Azure: no config change needed — /api/agent is not in the client-cert
exclusion paths (agent/ingest was always cert-enforced) and the
/api/bootstrap prefix exclusion remains for the surviving bootstrap
routes (verified via ARM on autopilotmonitor-api).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 08:53:20 +02:00
Oliver Kieselbach a56ffa68df chore: daily-review follow-ups (MCP caps, embed norm assert, projection tests, Classify guard)
Addresses four items from the daily code review, plus in-flight Web work and
the .mcp.json endpoint switch to the custom domain.

Review follow-ups:
- MCP access-guard: bound BOTH rate-limiter maps (preAuthBuckets + rateBuckets)
  via boundedSet on insert (new MAX_RATE_BUCKET_ENTRIES, default 10_000). They
  previously only shrank via the 5-min reaper, so a distinct-key flood could grow
  them unbounded between passes. Adds getRateBucketSizes() + a flood-cap test.
- MCP vector-search: assertUnitNorm() in embed()/index() (eps=1e-3, fail-loud).
  cosineSimilarity is a bare dot product that assumes L2-unit vectors; a violated
  normalize:true contract would otherwise silently corrupt ranking. +unit tests.
- Backend: equivalence tests for the bcd307d SessionsIndex projections, after the
  b417963 pattern. Extract MetricsSummaryProjection/QuickSearchProjection named
  arrays + testable TallyMetricsSummaryRow/ReadQuickSearchIdentity helpers; make
  BuildQuickSearchResult internal. New Metrics/QuickSearch ProjectionEquivalence
  tests derive their keep-set from the production array (bite-verified: dropping
  Status/DeviceName fails them).
- Agent V2 ModernDeploymentTracker: enforce the Classify() call-once invariant
  with a DEBUG-only [ThreadStatic] permit guard. Classify mutates the harmless-
  rollup counter, so a double-call double-counts; the invariant was comment-only.
  ProcessRecord/ProcessEvent issue one permit before their single call; Classify
  asserts + consumes it. Compiled out in RELEASE -> zero production impact.

Also included (per request):
- .mcp.json: point at https://mcp.autopilotmonitor.com/mcp (custom domain).
- Web useAggregatedAdminScope hook + seed + tests (separate in-flight work).
- Agent buildcounter.txt bumps.

Tests: MCP 338, Backend 2551, V2.Core 1538, Web hooks 31 - all green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 15:35:51 +02:00
Oliver Kieselbach a993070840 feat(ops): include per-orphan tenant/session breakdown in OrphanEventsCleaned
The OrphanEventsCleaned ops-event only persisted counts; the per-orphan
detail (which tenant/session lost lingering events) was logged via worker
LogInformation, which does not reach App Insights, so it was effectively
lost. Enrich the event details with a capped (top-50 by EventCount) list of
{tenantId, sessionId, eventCount} plus a detailsTruncated flag, reporting the
actual rows deleted. Keeps the Table row under the 32KB property limit.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 17:32:37 +02:00
Oliver Kieselbach 4c6cd8e0ce Merge pull request #80 from okieselbach/perf/backend-read-projections-sinceutc
perf(backend): index projections, windowed AppInstall reads, projected stats drain
2026-06-30 17:17:11 +02:00
Oliver Kieselbach a7c25aa7de fix(metrics): share one window start between session and AppInstall bounds
Follow-up to the sinceUtc bounding (commit "project index scans + bound
AppInstall reads to window"): the AppInstall cutoff was computed with a fresh
DateTime.UtcNow.AddDays(-days) AFTER the session window had already been
queried and several aggregates had run. That second snapshot sits a few ms
later than the session query's, so the AppInstall lower bound was strictly
later than the session lower bound.

A session included right at the window's lower boundary could then keep its
row while app summaries whose StartedAt fell into the gap between the two
cutoffs were dropped — undercounting AppScripts.AvgAppsPerSession and
TotalUniqueApps for boundary sessions.

Capture a single `now`/`windowStart` snapshot at the top of both
ComputeUsageMetricsInternalAsync and ComputeTenantUsageMetricsInternalAsync
and reuse it for GetSessionsByDateRangeAsync(windowStart, …) and
Get(All)AppInstallSummariesAsync(windowStart). Now both bounds are identical.

UsageMetricsWindowConsistencyTests pins it: it captures the start arg passed
to the session repo and the cutoff passed to the app repo and asserts they
are equal (and thus <=), on both the platform and tenant paths. Verified the
test bites — reverting either call to a fresh UtcNow fails it. All 2546
backend tests pass.

Reported-by: Codex review

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 17:06:58 +02:00
Oliver Kieselbach b41796344e perf(backend): project GetSessionStatsAsync drain to the columns it tallies
GetSessionStatsAsync drained the entire window via GetSessionsAsync, which
materializes every ~40-column SessionsIndex mirror row (incl. the large
FailureSnapshotJson, all Os*/Geo*/PendingActionsJson columns) — but
AggregateSessionStats only reads Status, StartedAt and DurationSeconds.

Drain through FetchSessionsPageInternalAsync with a narrow projection
{ PartitionKey, RowKey, SessionId, Status, StartedAt, CompletedAt, DurationSeconds }:
- PartitionKey/RowKey/SessionId: TenantId map + page-cursor derivation.
- CompletedAt: ComputeEffectiveDuration's fallback for a Succeeded session
  with no stored DurationSeconds — kept so the tally value is identical.
- IsPreProvisioned/ResumedAt deliberately omitted: they only feed the
  WhiteGlove Part-2 duration branch, which is gated on status==InProgress,
  and AggregateSessionStats never reads DurationSeconds for InProgress
  sessions — so dropping them cannot change any reported value.

FetchSessionsPageInternalAsync gains an optional `select` param defaulting to
null (= full row), so its only other caller (GetSessionsPageAsync) is
unchanged. Cursor/HasMore/pre-migration fallback mechanics are untouched.

DurationSeconds has a history of display/calc bugs, so equivalence is pinned
by SessionStatsProjectionEquivalenceTests: it reduces a full mirror row to
exactly SessionStatsProjection (derived from the production array, so a future
column drop fails the test), maps both shapes through the real production
mapper, and asserts identical DurationSeconds + identical SessionStats across
a mixed fleet — including a WhiteGlove Succeeded (drops IsPreProvisioned/
ResumedAt) and a CompletedAt-fallback Succeeded. Verified the test bites:
removing CompletedAt from the projection fails it. All 2544 backend tests pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 16:41:38 +02:00
Oliver Kieselbach bcd307d8a4 perf(backend): project index scans + bound AppInstall reads to window
Two classes of read-efficiency fixes on hot/aggregation paths. No response
shape or value changes — DTOs untouched, all 2541 backend tests pass.

P2 — select projections on full-mirror SessionsIndex scans (bandwidth only):
- GetMetricsSummaryAsync: cross-partition status tally now projects
  [PartitionKey, Status] instead of the ~40-column mirror row (incl. the
  large FailureSnapshotJson) -> ~10-20x less transfer.
- QuickSearchSessionsAsync: typeahead scan projects only the matched fields
  (+ RowKey for the SessionId fallback) instead of the full row, for up to
  QuickSearchMaxScannedRows rows per request.

P3 — push the existing sinceUtc window floor server-side at 8 AppInstall
call sites across 5 files (previously each dematerialized the whole table /
partition and filtered to the window in memory):
- UsageMetricsService (global + tenant): now.AddDays(-days)
- MaintenanceService.Aggregation (global + tenant, per aggregated day): targetDate
- SlaBreachEvaluationService (current ISO week): now.AddDays(-8) margin
- SlaMetricsService: startDate (matches the in-memory lower bound exactly)
- Get(Global)GeographicLocationSessionsFunction: cutoff

Each floor equals the session-window floor the apps are joined against
(app.StartedAt >= session.StartedAt >= floor), so results are unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 16:16:03 +02:00
Oliver Kieselbach 4da8f2d42d perf(agent-v2): skip costly format on rollup-suppressed ModernDeployment bursts
Three steady-state CPU/thread savings in the V2 collector layer, no wire-behavior
change:

- ModernDeploymentTracker: extract Classify() (EventID/level/channel only, never
  the formatted description) so ProcessRecord skips record.FormatDescription()
  entirely for rollup-suppressed records. The Autopilot channel has no EventID
  filter, so harmless-ID bursts (session 8bc1180f: ~689 EventID-100 records/min)
  no longer load the provider message-resource DLL just to be discarded. The
  testable ProcessEvent core keeps its exact signature and shares Classify, so the
  rollup counter still increments exactly once per event.
- NetworkChangeDetector: OnDebounceElapsed -> async void + ProcessNetworkChangeAsync
  awaits instead of Task.Delay().Wait()/RunConnectivityChecksAsync().Wait(); frees
  the pool thread instead of parking it ~5-13s per network change.
- ProvisioningStatusTracker: guard the per-category raw-JSON Trace interpolation
  behind a LogLevel>=Trace check so the string isn't materialized when Trace is off.

V2.Core 1538/1538, DecisionCore 400/400 green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 15:55:19 +02:00
Oliver Kieselbach 3ed38f5001 harden(mcp): non-root container + fail-closed role-catalog snapshot test
H-3: Dockerfile drops root — `USER node` before HEALTHCHECK/CMD (after npm ci
so the build stays root) + chown /app/.hf-cache (the only runtime write path:
the boot fallback downloads the model there). All read paths are already
world-readable; a recursive chown of /app is avoided to not bloat the layer.
NOTE: not verified by a real docker build (no local docker) — verify on deploy
that `id`=uid 1000 and the boot log says "(precomputed)", not the fuse fallback.

Role gating: new "role catalog snapshot — privilege-leak guard" test pins the
exact per-role tool set from one GA master list minus named diff lists
(RAW_GA_STRICT / PLATFORM_ONLY / DELEGATED_HIDDEN), mirroring the backend
EndpointAccessPolicyCatalog discipline. A tool added with a wrong/copy-pasted
role guard now fails the GA master or a derived-role assertion instead of
silently leaking. Consolidates the prior scattered contains/not-contains role
tests into this one source of truth.

Event-type drift: add a canonical.size>40 plausibility floor so a silently
broken C# extraction heuristic fails loudly instead of passing vacuously.

Test-only + Dockerfile; no production code touched. vitest 330 passed/41
skipped, tsc --noEmit clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 15:14:37 +02:00
Oliver Kieselbach e97ca57602 perf(mcp): cut search CPU, dot-product cosine, JSON+gzip transport
P2: hoist dedupeSynonymConcepts out of scoreEvent's per-event hot loop —
it is query-invariant but was recomputed (with Set/Map allocs) for every
fetched event (up to ~3200 per search_events call). Compute once per query
and pass in; standalone callers (tests) keep the old self-computing path.

P3: cosineSimilarity -> dot product. All embeddings are L2-normalized at
creation (embed()/index() use normalize:true; precomputed index built via
the same path), so the norm denominator is 1 — the two norms + sqrts +
divide per document per query were wasted work.

P7: precompute the ~130-type event catalog's word splits once at module
load instead of re-splitting every type on every keyword in the two
full-catalog scans (candidate selection + specificity) run per query.

P4: switch the stateless transport to enableJsonResponse and add the
compression middleware. This server emits no notifications, so SSE buys
nothing; JSON responses let gzip safely compress large tool results
(measured 5-30x, sub-ms CPU at level 6, skipped under the 1KB threshold).
Pretty-print kept — gzip removes the indentation overhead on the wire.

tsc clean; 329 vitest passed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 14:46:05 +02:00
Oliver Kieselbach bc6bac6242 fix(mcp-security): count cached deny verdicts against the pre-auth limiter
Follow-up to 84fe7575 (v1.4.4 -> 1.4.5). Deny verdicts (allowed:false) are
cached for 5 min, and the cache-hit path returned the cached 403 BEFORE the
per-source-IP pre-auth limiter ran. So an attacker could repeat one forged or
denied token and farm unlimited cheap cached 403s for the whole TTL without ever
tripping the 429 throttle (verified: MCP_PRE_AUTH_RATE_LIMIT_PER_MINUTE=1 gave
fetch=1 and 403,403,403,403).

Fix: on a cache hit, count a cached allowed:false against isPreAuthRateLimited
(clientIp) and return 429 once the budget is exhausted. Cached allowed:true stays
free so legitimate users never burn the pre-auth budget on their cached fast-path.

New regression test access-guard-preauth.test.ts: with the limit pinned to 1,
a repeated denied token yields [403, 429, 429, 429] with a single backend call,
and a repeated allowed token rides the cache 4x with no throttle.
vitest 329 passed / 41 skipped; tsc clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 14:22:55 +02:00
Oliver Kieselbach 84fe7575ba fix(mcp-security): host-spoof fail-fast, auth-fetch timeout, layered rate-limit + bounded cache
Addresses the 3 findings from the 2026-06-30 MCP security review (v1.4.3 -> 1.4.4).

M3 host-header spoofing: centralize getPublicBaseUrl into config.ts (deletes the
duplicated copies in oauth.ts + access-guard.ts) and fail-fast at boot in production
when MCP_PUBLIC_URL is unset, instead of silently trusting caller-supplied
X-Forwarded-Host. Safe for the stage-1 deploy (minReplicas=0 -> no container runs).

M2 access-check hang: add AbortSignal.timeout (default 15s, env-overridable) to the
/api/auth/mcp fetch -- it was the only fetch without a timeout, on the path every
request traverses. Timeout falls through to the existing fail-closed catch.

M1 pre-auth flood + unbounded cache:
- boundedSet caps accessCache (default 10k, oldest-evict) -> no OOM under a
  distinct-token flood on the 0.5GiB container.
- per-source-IP pre-auth limiter (isPreAuthRateLimited, default 120/min) on the
  cache-MISS path only, keyed on IP not UPN so a forged token never burns a
  victim's per-UPN budget. Kept LAYERED with the existing per-UPN limiter
  (IP for pre-auth, identity for authenticated -- the SaaS-standard split).
- app.set('trust proxy', 1) for a spoof-resistant req.ip (single Envoy hop).
- parsePositiveInt fixes the RATE_LIMIT NaN-disables-limiter bug and guards all
  new tunables; cleanup interval prunes the pre-auth bucket map.

Monitoring/UX: both 429 paths go through respondRateLimited -> structured greppable
log (kind=pre-auth|upn), Retry-After header, and an actionable body message.

Tests: parsePositiveInt, boundedSet, isPreAuthRateLimited, getPublicBaseUrl
fallback (security-guards.test.ts) + MCP_PUBLIC_URL pin (config-public-url.test.ts).
tsc clean; vitest 327 passed / 41 skipped.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 12:30:36 +02:00
Oliver Kieselbach 44203423ec feat(rules): ANALYZE-ESP-004 soft-failure for ESP-timeout + Continue-anyway
Devices that hit the ESP terminal timeout in DeviceSetup on a profile that
allows "Continue anyway" are recorded as Failed even though the user most
likely dismissed the error and reached the desktop. The existing advisory-
defang only covers AccountSetup failures, so DeviceSetup timeouts (e.g. a
slow blocking app missing the 30-min wall) show up as hard failures.

Add a built-in, enabled-by-default analyze rule (severity: warning) keyed on
enrollment_failed.mayHaveContinuedAnyway==true. It surfaces the soft failure
and points at the root cause (slow blocking app / ESP timeout / detection
mismatch / masked certificate failure) without changing session lifetime or
the terminal status. No agent change required; ships via EmbeddedResource or
ReseedFromGitHub.

Tests: RuleEngineEspSoftFailureTests (fires on the continue-anyway shape,
stays silent on hard failures and when the flag is absent).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 00:47:11 +02:00
Oliver Kieselbach 87f54e1523 fix(mcp-oauth): force Entra account picker (prompt=select_account)
VS Code opens the system default browser for the auth flow, which silently
reused whatever Microsoft account that browser already had an SSO session
for — authenticating as the WRONG user with no chance to switch. Working
around it by pasting the authorize URL into another browser profile fails
with ERR_CONNECTION_REFUSED, because the native clients loopback callback
listener (127.0.0.1:<port>) has already closed by the time the user finishes
in the second browser.

Add prompt=select_account to the Entra authorize redirect so the account
picker always appears in the same browser the client opened. The user can
pick the correct account inline, the loopback callback stays alive, and the
flow completes. Bumped to 1.4.3.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 23:00:33 +02:00
Oliver Kieselbach e6c570c3c0 fix(mcp-oauth): RFC 9728 resource identifier = <base>/mcp (fixes VS Code)
THE actual cause of VS Code / GitHub Copilot looping on "Waiting for server
to respond to initialize". VS Code validates the protected-resource metadata
strictly per RFC 9728 and rejected ours, so it never started the OAuth flow
(no /oauth/register, no /oauth/authorize, no browser prompt — confirmed via
container logs showing a single unauthenticated initialize, and via VS Codes
own log):

  "Protected Resource Metadata 'resource' property value ".../" does not
   match expected value ".../mcp" ... Per RFC 9728, these MUST match."
  "Failed to fetch ... /.well-known/oauth-protected-resource/mcp: 404"

The protected resource is the MCP endpoint <base>/mcp, but we advertised
`resource: <base>` (bare) and only served the metadata at the bare
well-known path. Fix:
  - `resource` is now `<base>/mcp` (matches the URL clients connect to);
  - the metadata is also served at the resource-path-specific location
    /.well-known/oauth-protected-resource/mcp (RFC 9728 §3.1), which strict
    clients fetch first;
  - the /mcp 401 WWW-Authenticate now points at that path-specific URL.

Unrelated to the signed-client-id refactor — it is a pre-existing
spec-compliance gap that this VS Code version enforces (Claude Code
validated leniently, so it always worked there). Bumped to 1.4.2.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 22:23:06 +02:00
Oliver Kieselbach 2e1ce68a30 chore(mcp): bump to 1.4.1 + log /mcp auth-failure paths for diagnostics
Bump version so the deployed build is visible at /health (it stayed 1.4.0
across several fixes, so neither side could tell what was live).

Add one log line to each /mcp 401/403 branch in accessGuard. The success
path stays quiet, but a stuck client — e.g. VS Code looping unauthenticated
`initialize` — is otherwise invisible because the happy paths emit nothing.
The trace records the JSON-RPC method and the failure reason so the next
connection attempt is diagnosable from container logs.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 21:55:12 +02:00
Oliver Kieselbach f783d609cf fix(mcp-oauth): RFC 8252 redirect_uri match (fixes VS Code initialize stall)
GitHub Copilot / VS Code stalled forever on "Waiting for server to respond
to initialize". Root cause is NOT initialize handling (the server answers
it correctly) but the per-client redirect_uri binding at /oauth/authorize:
it used an exact-string includes() match, while VS Code registers the
loopback URI without a trailing slash (http://127.0.0.1:PORT) and then
authorizes with the URL-normalized form (http://127.0.0.1:PORT/). The exact
match returned false -> 400 invalid_request at /authorize -> no code -> no
token -> initialize never authorized -> perpetual wait. Reproduced live
against the deployed server.

Replace the exact match with canonicalizeRedirectUri + redirectUriMatches:
  - parse + re-serialize so a trailing-slash / serialization difference
    compares equal;
  - drop the port for loopback hosts, per RFC 8252 section 7.3 (the AS must
    allow any port for loopback redirect URIs) — this also covers the
    ephemeral-port-per-run behaviour of native clients. The host allowlist
    already permits any loopback port, so this grants no new reach.
Non-loopback hosts (e.g. vscode.dev) keep full host+port+path matching.
Applied at both /oauth/authorize and /oauth/callback.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 21:45:36 +02:00
Oliver Kieselbach 384ac0d380 fix(mcp-oauth): require client_id, reject empty callback code, test callback
Address three review findings on the OAuth proxy:

1. (Medium) client_id was optional at /oauth/authorize, falling back to the
   host allowlist alone — bypassing the per-client redirect_uri binding by
   simply omitting client_id. The cold-start reason for tolerating absence
   is gone now that client_id is a stateless, replica-portable signed token,
   and every conforming MCP client registers first. client_id is now
   REQUIRED (400 invalid_request when missing).

2. (Medium/Low) /oauth/callback signed-state + client enforcement had no
   route-level tests. Added coverage: valid signed state+client forwards the
   code, forged client_id, redirect_uri not registered to the client,
   tampered state, missing state, expired state (10-min replay window), and
   the missing-code case from finding 3.

3. (Low) /oauth/callback redirected with code=undefined when Entra returned
   neither error nor code. Now returns 400 invalid_request on a missing code
   instead of forwarding a malformed query.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 21:19:45 +02:00
Oliver Kieselbach bbb2f444aa refactor(mcp-oauth): stateless signed client_id (multi-replica safe)
Replace the in-memory dynamic-client registry with a self-describing,
HMAC-signed client_id that encodes the registered redirect_uris. The
Container App is stateless with scale-to-zero and may run multiple
replicas, so the old Map was wiped on cold start and never shared across
replicas -- a client_id minted by /oauth/register read as unknown at
/oauth/authorize, surfacing as invalid_client.

The signed token is verifiable on any replica holding the same root
secret (OAuthStateSigningKey, already deployed), so:
  - /oauth/authorize now verifies the signature statelessly and FAILS
    CLOSED on a forged/unknown client_id (back to 400 invalid_client),
    while keeping the exact per-client redirect_uri match. This removes
    the earlier cold-start fail-open without reintroducing the bug.
  - /oauth/callback verifies the same way (the redirect_uri is already
    pinned in the HMAC-signed state).
  - /oauth/register returns the signed token; no storage, no memory cap.

The client_id signing key is domain-separated from the proxy-state key
via a labelled HMAC, and the payload carries a `typ` discriminator, so a
state token can never be replayed as a client_id or vice versa. This is
the prerequisite for raising maxReplicas above 1; remaining per-instance
state (accessCache, rate-limit buckets) degrades gracefully, not
incorrectly.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 21:06:52 +02:00
Oliver Kieselbach b2cc3182c8 fix(mcp-oauth): tolerate unknown client_id at /authorize (cold-start)
The dynamic-client registry is in-memory, and the MCP server is stateless
with scale-to-zero and possibly multiple replicas. Between /oauth/register
(called by the client) and /oauth/authorize (opened later in the browser)
the container can sleep or a different replica can serve the request,
wiping the registry. /authorize then hard-failed with
"invalid_client — Unknown client_id — register via /oauth/register first".

/oauth/callback already tolerates this cold-start case (registry wiped ->
fall back to the redirect_uri host allowlist). Mirror that at /authorize:
an unknown client_id now proceeds on the host-allowlist gate (+ mandatory
PKCE S256) instead of dead-ending. Known client_ids still get the exact
per-client redirect_uri match. No weakening of the security model -- the
host allowlist and PKCE were always the real gate; client_id matching is
defense-in-depth.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 20:47:35 +02:00
Oliver Kieselbach 75d49a8db4 feat(mcp-oauth): allow VS Code / GitHub Copilot DCR redirect hosts
GitHub Copilot in VS Code registers two redirect_uris during dynamic
client registration: a loopback URI (http://127.0.0.1:<ephemeral-port>,
already accepted by the RFC 8252 loopback rule) and a hosted URI
(https://vscode.dev/redirect, or https://insiders.vscode.dev/redirect on
Insiders). The latter was not in DEFAULT_ALLOWED_HOSTS, so /oauth/register
rejected the whole registration with 400 invalid_redirect_uri. VS Code
surfaces this as "Dynamic Client Registration not supported" and falls
back to the manual client-id dialog.

Add vscode.dev + *.vscode.dev to the default allowlist so Copilot can
complete DCR unattended. Security posture is unchanged: PKCE S256 stays
mandatory and the per-client redirect_uri match still pins the exact URI
(incl. loopback port) at /authorize and /callback.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 19:50:07 +02:00
Oliver Kieselbach 0abccf1fe0 refactor(delegated): rename TenantTemplate -> TenantGroup across the full stack
Completes the Tenant Templates -> Tenant Groups rename down to identifiers,
tables, routes and DTOs (the earlier commit renamed only the UI labels). Done
now while the table is effectively empty; the new TenantGroups /
TenantGroupAssignments tables are auto-created on deploy, the operator re-enters
their group, and the old TenantTemplates table is dropped in Azure manually.

- Tables: TenantTemplates -> TenantGroups, TenantTemplateAssignments ->
  TenantGroupAssignments (Constants + All).
- Entities/DTOs: TenantTemplate(Entity)/TenantTemplateAssignment(Entity) ->
  TenantGroup*, property TemplateId -> GroupId.
- Repo/service/API: all *Template* methods -> *Group*; routes
  /api/global/tenant-templates -> /api/global/tenant-groups, {templateId} ->
  {groupId}; function class + file renamed; audit entity DelegatedTemplateAccess
  -> DelegatedGroupAccess; NormalizeTemplateId -> NormalizeGroupId.
- Offboarding wipe list + tests updated.
- Web: api.tenantGroups, SectionTenantGroups (file renamed), response key groups.

Scoped strictly to the feature: unrelated 'template' usages (analyze-rule
templates, EmailTemplates, interpolate-rule-template) left untouched. Backend
2538 tests green; web tsc clean + vitest 494 green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 19:47:05 +02:00
Oliver Kieselbach bce5d90c2a refactor(web): rename Tenant Templates -> Tenant Groups in the UI
User-facing label is clearer as a 'group of tenants' than 'template'. Renames
the menu label, page header, copy and the frontend route slug
(/admin/settings/tenant-templates -> /admin/settings/tenant-groups). Backend
(tables, classes, /api/global/tenant-templates routes) intentionally unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 19:30:51 +02:00
Oliver Kieselbach 3e827fbb92 fix(delegated): tenant-template membership write failed (Edm.DateTime out of range)
AddTenantToTemplate returned 500 on the first add: the membership-row write
threw because TenantTemplateEntity.CreatedDate was left at default(DateTime)
(0001-01-01), which is below Azure Table Storage's Edm.DateTime minimum (1601).
The meta row wrote fine only because Create stamps CreatedDate=UtcNow.

Make CreatedDate nullable so membership rows omit it (null is valid); meta rows
still carry it. Read-back maps null -> default. Confirmed via storage: the
affected partition had only the meta row, no membership row.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 18:53:39 +02:00
Oliver Kieselbach 3d6933c3f4 updated modified timestamps 2026-06-29 16:33:47 +02:00
Oliver Kieselbach 10cb8820ac feat(global-scope): persist tenant-switcher selection across pages (sessionStorage)
The cross-tenant (Global Admin / delegated MSP) tenant switcher kept per-page
useState, so the selection was lost on every navigation. Persist it in
sessionStorage for the tab lifetime so each monitoring page lands on the last
picked tenant.

- New SSR-safe utils/tenantScopeStorage.ts ("" = aggregated, guid = tenant,
  null = unset); fails soft on disabled/quota storage.
- Both scope hooks seed from storage and persist ONLY on explicit user action,
  so a GA aggregated ("") intent survives a detour through an override-only page
  (which resolves "" locally to the home tenant without clobbering storage).
- Fallbacks: GA -> home tenant, delegated -> first managed tenant; stale/out-of-
  scope persisted values self-heal. GA-mode toggle restores the persisted tenant.
- Drop the apps-only ?global=1 round-trip (scope now comes from storage); keep
  ?tenantId= as an optional, server-validated deep-link seed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 16:33:17 +02:00
Oliver Kieselbach 86f8fd5ace feat(delegated): tenant templates — app-internal tenant bundles for MSP delegated admins
Let a GlobalAdmin define a named Tenant Template (a bundle of tenants) and
assign a delegated-admin UPN to the template instead of to each tenant. A UPN's
effective scope = union of direct grants + every tenant in every assigned
template, resolved in DelegatedAdminService.GetScopeAsync — so middleware
enforcement, /auth/me delegatedTenantIds and MCP all inherit it unchanged.
Adding a tenant to a template grants it to all assignees at once (membership-
driven MSP management). Fully app-internal: no Entra group claim / Graph.

- Storage: TenantTemplates (PK=templateId; meta + membership rows) and
  TenantTemplateAssignments (PK=upn) tables; meta-backed existence invariant in
  both management reads AND the auth hot path (no ghost templates, no scope from
  stray rows).
- Offboarding hygiene: property-only wipe now purges TenantTemplates membership
  AND DelegatedAdmins rows for the offboarded tenant (closes a pre-existing
  orphaned-grant gap).
- Management API: GlobalAdminOnly /api/global/tenant-templates/* (8 routes);
  per-affected-tenant audit; all mutations route through the service so the
  delegated-scope cache is invalidated in lockstep; existence/membership
  invariant owned by the service (no ghost create, no false revoke audits);
  templateId normalized case-insensitively at the service boundary.
- Web: SectionTenantTemplates management UI (create/rename/delete, add/remove
  tenants, assign/unassign), triple-registered + api.ts block.

Backend 2538 tests green; web tsc clean + vitest 488 green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 16:17:04 +02:00
Oliver Kieselbach ecb1e14cf5 fix(ui): align script-execution duration after status on remediation cycle header
The multi-phase remediation cycle header rendered the run duration before the
status label, while every single row (platform, detect-only) shows duration
after the status. Reorder the header so duration consistently trails the status
everywhere.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 15:40:31 +02:00
Oliver Kieselbach 11fc0560fc feat(remediation): surface run duration on remediation scripts
Platform scripts already report a per-script run duration (StartedAtUtc
captured at the start line -> durationSeconds on script_completed). This
extends the same timing to proactive-remediation (health) scripts.

The adapter's duration block is type-agnostic; it just needs StartedAtUtc
on the emitted state. Remediation phase events are built from the
[HS] new result JSON, which carries no timestamps, so the cycle start is
captured from the HS-SCRIPT-START line into a dedicated per-policy map
(_pendingHealthScript is cleared by the early-signal HS-COMPLIANCE handler
before the result arrives) and stamped on every phase event when
HS-NEW-RESULT is parsed. Duration is the whole-cycle runtime
(detection + remediation + post-detection) since IME provides no per-phase
timestamps. Null when the start line was never seen (replay) -> omitted.

Web lifts the cycle duration onto the ScriptCard and shows it once on the
card header rather than repeating the same value on each nested phase row;
detect-only single-phase cards keep showing it on their row.

V2-only. Tests: 2 agent timing tests (with/without observed start), 3 web
card-duration tests.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 12:22:30 +02:00