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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
Refines a4171510. A GlobalReader is "GA minus writes" and must see the SAME
sidebar as a real Global Admin:
- Standard monitoring/rules/operations/configuration groups (Fleet Health,
Usage Metrics, SLA, Geographic, Software, Audit, ...) were gated on
`adminOrOperator`, so a pure GlobalReader (no own-tenant role) never saw
them. Open them to any platform scope: `isAdminOrOperator || hasGlobalScope`.
- The (purple) Global Admin section is gated back on the Global View toggle
(`hasGlobalScope && globalAdminMode`) -- IDENTICAL to a GA: toggle off hides
it, toggle on shows it. (a4171510 had decoupled it from the toggle; reverted
so reader == GA exactly.)
Read-only is enforced inside the pages (useCanMutatePlatform + backend) and by
the existing item-level globalAdminOnly filter (Settings/Ops/Software drop out
for a reader), not by hiding the data nav. The "fleet" MSP group stays
delegated-only, so a GA/Reader gets the standard groups with no duplication.
tsc clean; web 488 tests green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The cross-tenant nav group (Managed Tenants, Fleet Health, Software,
Geographic, SLA, Usage, Audit...) was gated on `hasGlobalScope &&
globalAdminMode`. globalAdminMode defaults to false (localStorage) and there
is only auto-DISABLE logic, no auto-enable, so a fresh GlobalReader (or a GA
with no own-tenant role) saw only "Dashboard" until they discovered the hidden
"Global View" toggle in the avatar menu -- a dead end. A delegated MSP admin,
by contrast, gets the full fleet nav with no toggle.
Decouple nav VISIBILITY from the toggle: the platform nav is reachable for any
caller with global scope (GA or read-only GlobalReader). The toggle now governs
only the DATA scope (own-tenant vs cross-tenant) of the pages, not whether the
nav is reachable. Mutating controls inside these views stay gated on
isGlobalAdmin. Removed the now-dead globalAdminMode dependency from the sidebar.
tsc clean; web 485 tests green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Phase 7 deleted the /fleet/[tenantId] session browser (which appended
?tenantId= via sessionLinkTarget) and re-routed delegated viewing through
the dashboard SessionTable, but the dashboard never passed sessionLinkTarget.
Row-click/search-nav went to a bare /sessions/{id}; the detail page resolves
the target tenant for a delegated admin SOLELY from ?tenantId= (no global
scope -> bare id falls back to the JWT home tenant -> wrong partition ->
empty events + "No issues detected").
- SessionTable.sessionLinkTarget signature (sessionId) -> (session): the
target tenant is per-row (session.tenantId), not derivable from the id.
- dashboard passes sessionLinkTarget when crossTenant ->
/sessions/{id}?tenantId=<session.tenantId>. Harmless for real GA.
tsc clean; web 485 tests green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The delegated rescue treats every GlobalReadOrAdmin + QueryParam/RouteParam route
as delegated-reachable, which had silently opened several platform-OPERATIONAL
global reads to delegated ("MSP") admins. Those should be Global Admin + read-only
Global Reader only - the platform operator's view, not the managed tenant's data.
- global/ops-events -> TenantScoping.None (Consent/Maintenance/Security/Tenant/
Agent/SLA - operator data). Its in-memory FilterByTenant is reframed as a
GA/Reader ?tenantId= drill (correctness, NOT a delegated-isolation boundary)
and extracted internal-static for direct test coverage.
- New EndpointPolicyEntry.ExcludeDelegated flag: the None-equivalent for routes
whose {tenantId} template forces RouteParam (the {tenantId}-convention test
forbids None there). The middleware ANDs !ExcludeDelegated into the delegated
rescue, so it can only RESTRICT access, never grant it. Applied to:
- global/customs-archive (+ the two drill reads)
- global/tenants/{tenantId}/deletion-manifests
- preview/notification-email/{tenantId}
GA + read-only Global Reader keep all of these.
Full reclassification of every delegated-reachable global/* route: customer
telemetry (sessions / metrics / apps / rules / audit-logs / mcp-usage / ...) stays
delegated-reachable; platform-operational + offboarding reads are now excluded.
Tests: Delegated_{OpsEvents,CustomsArchive,DeletionManifests,PreviewNotificationEmail}_*
+ GlobalReader_*_Allowed + catalog Theory PlatformOperationalReads_ExcludeDelegated.
Backend filtered tests green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Security-review hardenings for the delegated ("MSP") admin feature.
- MCP kill-switch (M1): a delegated admin's MCP access was auto-granted under
WhitelistOnly BEFORE consulting their McpUsers row, so an explicit Disabled
row was ignored. ResolveWhitelistStateAsync now reads a tri-state and a
Disabled row revokes a delegated caller's MCP access (a MISSING row does not
- the normal delegated case). Operators get one lever without unwinding the
delegated grants.
- Web defense-in-depth (M3): the dashboard session list + stats and the
geographic-sessions drill bind a free-typed / deep-linked ?tenant= to the
caller's delegatedTenantIds before sending (shared boundTenantToDelegatedScope).
The backend already bounds this; the client now never even asks for an
unmanaged tenant.
- Geo-sessions drill shows the blue "Delegated Admin View" banner (L2).
Backend filtered tests + web tsc + vitest (485) green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The tenant notification-group join/leave checks gated on the caller's HOME-tenant role
(IsTenantAdmin / IsTenantMemberOrGlobalAdmin) without verifying the group belonged to that
tenant. Once this branch admits a cross-tenant delegated ("MSP") reader past the broadcast
cross-tenant gate, a home-tenant Admin/member role let them join a DIFFERENT (managed)
tenant's admin/member notify group and receive its higher-tier notification pushes -
breaking the documented "delegated caller gets no notification pushes" contract.
Fix: a single pure gate SignalRGroupHelper.CheckNotifyGroupAccess binds the role to the
GROUP's tenant - admin tier = (sameTenant && IsTenantAdmin) || IsGlobalAdmin; member tier =
(sameTenant && IsTenantMemberRole) || HasGlobalScope. Add/Remove both call it (symmetric).
Preserves own-tenant Admin/member, GA cross-tenant, and GlobalReader cross-tenant member
read; closes the same latent gap for a GlobalReader who is also home-admin.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Review finding (High). GetAllSessionsPageAsync / GetAllSessionsAsync route any non-empty
tenantIdFilter straight to the per-tenant scan, ignoring allowedTenantIds. The middleware
only resolves the delegated allow-list for a cross-tenant target (namedTarget != JWT
tenant); when ?tenantId= equals the caller's own JWT/home tenant, crossTenant is false so
the scoped-route check is skipped — but /api/global/sessions is GlobalReadOrDelegatedSubset,
so a delegated-only caller is still admitted (with AllowedTenantIds for their managed set).
The repo then drilled the home tenant anyway → a delegated MSP user could read their home
tenant's sessions/stats without managing it or being a member.
Fix: new DrillOutsideBound guard — when allowedTenantIds != null and tenantIdFilter is not
in that set, return empty before the per-tenant scan. Applied to both the page (sessions
list) and the drain (stats funnel through GetAllSessionsAsync). The middleware contract is
unchanged by design (admit + publish AllowedTenantIds, handler/repo bounds); the bound now
holds for the home-tenant case too.
Tests: DelegatedBoundedAggregateTests.BoundedDrill/Stats_UnmanagedTenant_* (drill an
unmanaged tenant → empty, the per-tenant SessionsIndex/Sessions scan never runs);
PolicyEnforcementMiddlewareTests.Delegated_GlobalSessions_HomeTenantDrill_AdmittedButNotInAllowedSet
pins that the middleware admits the home-tenant drill but never puts it in AllowedTenantIds.
Backend 2489 green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Review findings.
High — delegated bounded aggregate could fall back to an unbounded all-tenant scan:
FetchAllSessionsPageInternalAsync filters the config tenant list by allowedTenantIds,
but when that intersection is empty (managed tenant has no config row, config
momentarily empty, or a casing mismatch) it still invoked the legacy unbounded
primary-table scan — leaking ALL tenants' sessions to a delegated caller on the
no-tenantId /api/global/sessions path. Fix: when allowedTenantIds != null, return an
empty page and NEVER touch the primary-table fallback (which stays the GA all-tenants
safety net). New DelegatedBoundedAggregateTests proves both: bounded+empty → empty with
the Sessions table QueryAsync never called; unbounded(GA)+empty config → fallback runs.
Low — override-only pages showed "Delegated Admin View — access to all tenants":
usage-metrics, sla, gather-rules, analyze-rules rendered the banner without a subtitle,
defaulting to the all-tenants wording that contradicts the bounded model. Now pass
globalAdminSubtitle(scope) so a delegated (always a concrete managed tenant) reads
"viewing tenant <x>", matching the aggregated pages.
Backend 2486 green, web 481 green + tsc clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Complete the two deferred analytics surfaces so the delegated ("MSP") experience is
consistent across every page.
Audit Log — now uses useAggregatedAdminScope (was raw globalAdminMode):
- GA/Reader keep the "All tenants" aggregate AND gain a per-tenant drill-down via the
scoped selector; a delegated admin gets the per-tenant dropdown only (no aggregate).
- globalLogs(opts) carries the selected tenantId; the backend GetGlobalAuditLogsFunction
already honors ?tenantId= (continuation fingerprint + nextLink bind it).
- Replaces the page's hard-coded purple bar with the shared GlobalAdminBanner (blue
"Delegated Admin View" for delegated). Re-added the Audit Log item to the Fleet nav.
Geographic session drill-in:
- The geographic page now carries the selected tenant into the drill-in link
(?tenantId=), and globalGeographicSessions(...) threads it through. The drill-in
treats ?tenantId= (or GA mode) as cross-tenant, so a delegated caller sees the
managed tenant's sessions for a location instead of a 403.
Web: tsc clean, 481 vitest green. Backend unchanged (both routes already QueryParam-scoped).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Make a delegated ("MSP") admin a "scoped Global Reader": the same pages a Global
Admin sees, but every tenant switcher/filter is bounded to the managed allow-list.
Reader path only (readWrite deferred). MCP stays read-only (Phase 6 already covers
delegated reads).
Backend (bounded aggregate — reuses the GA per-tenant fan-out):
- global/sessions + global/stats/sessions: GlobalReadOrAdmin -> GlobalReadOrDelegatedSubset
(keep QueryParam). A delegated caller WITHOUT ?tenantId= is admitted as a bounded
aggregate; the handler restricts the cross-tenant fan-out to RequestContext.AllowedTenantIds.
With ?tenantId=X it drills into one managed tenant (middleware validates X in scope).
- TableStorageService: GetAllSessions{,Page,Stats}Async gain an optional allowedTenantIds
set; FetchAllSessionsPageInternalAsync swaps its Step-1 tenant source to that subset
(everything downstream — per-tenant query, inverted-tick merge, cursor — is reused).
- IsDelegatedReadTier += GlobalReadOrDelegatedSubset so the single-tenant drill rescue fires.
- SignalR AddToGroup/RemoveFromGroup: admit a delegated caller to a managed tenant-{id}
group (in scope); global-admins stays platform-scope-only.
Web:
- useAggregatedAdminScope / useGlobalAdminScope: cross-tenant mode is now on for a delegated
admin too (isGlobalAdmin = crossTenant; new isDelegatedScope). Tenant list bounded to the
managed subset; always a concrete tenant (no "All tenants" aggregate on analytics pages).
- TenantScopeSelector hides the aggregate option for delegated; GlobalAdminBanner gains a
blue "Delegated Admin View" variant (wired on all analytics pages + dashboard).
- Nav: Fleet group exposes Dashboard + Fleet Health + Software + Geographic + SLA + Usage;
showDashboard now includes delegated.
- Dashboard delegated-ready: crossTenant drives the /global endpoints + filter UI; empty
filter = bounded aggregate over managed tenants, a selected tenant = drill-in; global-admins
SignalR join stays real-GA-only; read-only (mutations already GA-gated). `?tenant=` deep-link
seeds the filter.
- Fleet is now a pure stats card grid; the per-tenant session browser /fleet/[tenantId] is
removed and cards link to /dashboard?tenant=<id>.
Deferred follow-ups: Audit Log + Geographic session drill-in for delegated (both diverge from
the scope-hook pattern / need tenantId threading).
Tests: backend 2484 green (catalog/middleware contract updated for the new subset tier),
web 481 green, MCP 292 green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Lead the tab title with the device identifier (deviceName, falling back
to serialNumber) so multiple session tabs stay distinguishable when
compared side by side -- tabs truncate to the first characters. Resets
to the bare app name on unmount to avoid a stale device name lingering
after client navigation.
Also aligns the root metadata title template on the en-dash separator
(was a pipe) so all titles are consistent.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Delegated (scoped-global / MSP) callers get cross-tenant ROUTING bounded to their
managed tenant set: every tool call must name a managed tenantId (no aggregate),
platform-only tools stay hidden, and enforceDelegatedTenant(ForPage) validates the
effective tenant client-side as defense-in-depth.
Backend: McpUserService resolves delegated scope and auto-grants MCP access under
WhitelistOnly; McpUserFunction emits delegatedTenantIds/delegatedRole. Recatalog
global/metrics/summary to QueryParam so delegated readers reach it only on their
managed ?tenantId= path (aggregate stays blocked). Authoritative enforcement is the
PolicyEnforcementMiddleware delegated rescue.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The search-suggestion CLICK handler still hardcoded /sessions/{id}, dropping the
?tenantId= that the fleet drill-in passes via sessionLinkTarget. A delegated-only
MSP user picking a suggestion would lose tenantIdOverride and useSessionDetail
would fall back to the home-tenant/null path, misrouting the managed-tenant load.
Route it through linkFor(...) like the row click and the keyboard-Enter path.
web tsc clean; 481/481 web tests.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Replace the bespoke fleet session list with the dashboard's actual session
browser, so the MSP per-tenant view has full parity: search, sort, status
filter, column selection, page-size selector, full-width toggle, and Next/Prev
pagination (which pulls more pages from the server as you advance) — exactly the
GA dashboard experience, scoped to one managed tenant.
- /fleet/[tenantId] now wires useDashboardFilters + the real SessionTable over
useTenantSessions data (globalSessions.list(tenantId) + continuation). Rendered
READ-ONLY: adminMode/globalAdminMode=false + user=null hide delete/block and the
cross-tenant filter box; tenantId=null keeps the filter engine from re-filtering
already-single-tenant data and hides global-only columns. Own full-width state
(fleet_fullWidth), read post-mount to avoid an SSR hydration mismatch.
- SessionTable gains one backward-compatible optional prop, sessionLinkTarget,
used by both row-click and search-suggestion navigation. Default is unchanged
(/sessions/{id}); the fleet view passes /sessions/{id}?tenantId= so rows open in
the managed tenant's read-only context. Dashboard behavior is untouched.
- Remove the now-orphaned utils/sessionFormat.ts and the hand-rolled SessionList.
web tsc clean; 481/481 web tests.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Fleet pagination:
- useTenantSessions now follows the continuation token: a "Load more" button on
the managed-tenant drill-in fetches and appends the next page instead of
capping at the first 100. A generation guard drops an in-flight loadMore if the
tenant/days switch mid-flight; errors stop pagination (loaded rows stay).
- /fleet/[tenantId] replaces the static "older sessions not shown" footer with
the Load more control (disabled while loading).
StatusBadge dedupe:
- Fold the two inline status-pill copies (dashboard SessionTable + geographic-
performance sessions) into the shared components/SessionStatusBadge. Extended
it with an optional adminMarkedAction prop (the dashboard's "manual" sub-badge);
the bare pill is returned unchanged when absent, so the geographic + fleet
output is identical. One definition now, dark-mode variants included.
web tsc clean; 481/481 web tests.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Make the fleet session rows open the FULL session-detail page for a managed
tenant — the main MSP use case (see WHY a session failed, not just THAT it did).
No new backend endpoint: the detail reads (GET sessions/{id} + /events +
/analysis + /vulnerability-report, and diagnostics/download-url) are all
MemberRead + QueryParam, which the Phase-1 delegated scope already rescues for
the assigned tenant. The write routes (mark-failed/succeeded/report =
TenantAdminOrGA) are NOT a delegated read tier, so the backend already denies
them — the page just must not offer them.
- useSessionDetail: read ?tenantId= from the URL and seed it into the tenant
resolution (the delegated caller has no global scope, so a null tenantId can't
resolve the session server-side — it needs the named tenant on the first GET).
Wins over the JWT home tenant; ignored once the session's own tenantId loads.
GA (globalAdminMode -> null) and own-tenant paths unchanged.
- Session detail page: isReadOnlyView = cross-tenant && !GlobalAdmin. Hides the
mutating controls (Succeed/Fail, Report Session, Diagnosis, Analyze Now,
Rescan). Seeded from tenantIdOverride so they never flash in before load.
Inspector stays Global-Admin-only (already gated). Download Diagnostics stays
(it's a MemberRead read).
- AnalysisResultsSection: canReanalyze prop (default true) gates "Analyze Now".
- Fleet /fleet/[tenantId] rows are now clickable -> /sessions/[id]?tenantId=.
Backend regression tests (PolicyEnforcementMiddleware): a delegated reader is
allowed GET sessions/{id}/events?tenantId=managed, and DENIED POST
sessions/{id}/mark-failed — locks the read-only drill-in.
Middleware tests 38/38; web tsc clean; 481/481 web tests.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Close the write path for MSP mode: a GlobalAdmin can now grant/revoke/enable/
disable delegated cross-tenant read assignments from the portal, instead of
hand-editing the DelegatedAdmins table (no more Boolean/lowercase/5-min-cache
foot-guns). Mirrors the MCP-user management surface.
Backend:
- DelegatedAdminManagementFunction at /api/global/delegated-admins: GET list
(GlobalReadOrAdmin), POST grant, DELETE {upn}/{tenantId} revoke,
PATCH .../enable|disable (all GlobalAdminOnly). Grant defaults role
DelegatedReader, stamps status=Active + source=OperatorGranted + grantedBy;
rejects an unrecognized role (fail-closed). Service.UpsertAsync invalidates
the caller's scope cache, so a grant takes effect on the next request.
- IAdminRepository.GetAllDelegatedAdminsAsync + TableAdminRepository impl
(small full-table scan, off the hot path) + DelegatedAdminService.GetAllAsync.
- Catalog: 5 routes. The {tenantId}-bearing mutation routes use
TenantScoping.RouteParam (fail-closed convention; sets TargetTenantId to the
managed tenant). Policy stays GlobalAdminOnly so a delegated caller cannot
manage delegations (GlobalAdminOnly is not a read tier).
- Mutations are audited under the TARGET managed tenant's trail (CREATE/DELETE/
UPDATE, entityType DelegatedAdmin), so the customer sees who was granted
access to their tenant.
Frontend:
- SectionDelegatedAdmins settings section: grant form (UPN + tenant dropdown +
role), assignments grouped by admin, enable/disable/revoke. Triple-registered
(globalNavConfig ga-settings + settingsNavSections + [section] map).
- api.delegatedAdmins family (list/grant/revoke/enable/disable).
Backend build clean; 2473/2473 backend tests; web tsc clean; 481/481 web tests.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>