- ConsoleBypassWatcher: re-check _disposed after watcher.Start() — a Dispose()
racing the background arm could leave an armed WMI subscription no code path
could ever stop (leaked until process exit).
- SignalRContext: collapse rapid accessRevoked pushes into one restart via an
in-flight guard — a second start() on a non-Disconnected connection throws
and burns the retry budget.
- config/all: negative-cache managed ids a rescue scan proved missing (5 min
TTL) — one stale grant (offboarded tenant) no longer makes every delegated
config/all call pay a full table scan.
- DelegatedAdmin/TenantGroup functions: extract testable revoke-enforcement
cores (RevokeCoreAsync, SetEnabledCoreAsync, UnassignCoreAsync,
DeleteGroupCoreAsync, ValidateGrantRequest) and cover the security flows:
404-on-missing-row writes no audit row and fires no disconnect; real
revoke/disable/unassign/group-delete always cuts live streams; grant
validation is fail-closed (GUID tenantId, recognized roles only).
- dashboard/page.tsx: drop unused next/link import left by the banner removal.
Tests: backend 3099 passed, web 557 passed, agent ConsoleBypassWatcher suite
passed, tsc clean.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
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>
Addresses a Codex finding: no handler-level coverage existed for the delegated
config/all filtering/redaction path (coverage was policy-context + projection only).
Following the repo convention (handler HTTP entry not unit-tested — see
GetAllBlockedDevicesFunctionTests), the security-relevant transform is extracted
into two pure, testable seams on GetAllTenantConfigurationsFunction and tested
directly:
- ExistingManagedConfigs: from the per-tenant point-read results, keep only managed
tenants that actually have a config row (exists==true); drop the rest. Tested:
drops missing rows, keeps present ones, empty when none exist.
- DelegatedBareArrayView: redact every managed config's secrets (a delegated admin
is never a Global Admin). Tested: secrets -> placeholder, non-secrets preserved,
original instance not mutated.
5 new tests; full Functions suite green (2469).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Addresses a Codex review finding: the Phase 2b delegated config/all path loaded
EVERY tenant config via GetAllConfigurationsAsync() (an O(all tenants) table scan
+ full-config materialization) and then filtered in memory. Because Phase 2b
opened this endpoint to delegated MSP users, a caller scoped to a few tenants
could trigger an all-tenants scan per request — response-bounded, not read-bounded.
Now point-read ONLY the caller's managed tenants via the non-creating
TryGetConfigurationAsync (cached; never writes a phantom default row), dropping
ids with no config row (exists==false). A delegated MSP scoped to k tenants does
k point reads instead of scanning n. The response is still filtered+redacted as
defense in depth; the set fed to it is now itself bounded.
Full Functions suite green (2464).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Introduces the GlobalReadOrDelegatedSubset policy tier — a cross-tenant READ that
is BOUNDABLE to a subset — and applies it to config/all (the tenant list). A
delegated ("MSP") admin can now list ITS managed tenants in one call, bounded to
its allowed set; a Global Admin / Reader still gets all tenants (unbounded).
This is the server-side linchpin for the MSP fleet view + tenant switcher: the
frontend gets the managed-tenant list (id + domain) here, then builds the
per-tenant fleet grid by fanning out over that subset using the Phase-2a
single-tenant /api/global/*?tenantId= endpoints (subset fan-out, client-orchestrated
over already-bounded per-tenant reads — no all-tenants code path for delegated).
- Middleware: new EvaluateGlobalReadOrDelegatedSubsetAsync — GA/Reader admitted
unbounded; a delegated caller with a non-empty scope admitted with its managed
set carried on the decision and published as RequestContext.AllowedTenantIds.
DecideAsync merges this into the delegated flags + AllowedTenantIds.
- GetAllTenantConfigurationsFunction: when AllowedTenantIds is present, BIND the
response to that subset (both the bare-array and paginated shapes) and always
redact secrets (a delegated caller is never a Global Admin). The subset is small,
so it is returned in one shot.
- Catalog: config/all -> GlobalReadOrDelegatedSubset. Aggregates that cannot be
bounded stay GlobalReadOrAdmin + None (delegated-unreachable).
- Tests: delegated config/all admitted with bounded AllowedTenantIds; GA unbounded
(null bound); non-delegated non-global denied; tier-assignment guard. Full suite green (2464).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
GlobalReader = cross-tenant READ visibility like a Global Admin, but no mutating
operations and no platform settings. Provisioned exactly like a GA: a GlobalAdmins
table row with Role=GlobalReader (empty/legacy Role = GlobalAdmin, back-compat).
Semantics are ADDITIVE — a user who is also their own tenant's Admin keeps edit
rights on that tenant; a pure GlobalReader is read-only everywhere.
Backend:
- GlobalAdmins.Role column + GlobalAdminService.GetGlobalRoleAsync; IsGlobalAdminAsync
(incl. TableAdminRepository) delegates so a GlobalReader row never reads back as admin
- RequestContext.IsGlobalReader + HasGlobalScope
- Split GlobalAdminOnly into GlobalReadOrAdmin (cross-tenant reads) vs GlobalAdminOnly
(writes/settings) + TenantAdminOrGlobalReader for config-read
- PolicyEnforcementMiddleware: extracted testable DecideAsync seam; cross-tenant gate keys
off HasGlobalScope; additive own-tenant role carried so IsTenantAdmin stays correct
- Config secret redaction for readers (RedactedCopyForReader) + save-time placeholder
restore (RestoreRedactedSecretsFrom) so a redacted view can't blank real secrets
- raw table/log tools stay GlobalAdminOnly; SignalR global-admins read group admits HasGlobalScope
- /auth/me emits isGlobalReader (+ preview-gate bypass, no auto-admin); /auth/mcp emits globalRole
MCP: hasGlobalScope (GA or Reader) drives routing + tool catalog; raw secret-bearing tools
(query_table/list_tables/query_backend_logs) gated strict-GA only.
Web: AuthContext isGlobalReader + hasGlobalScope; reader reaches /dashboard + cross-tenant
views; platform Settings/Ops/Backups/Software GA-only (nav-hidden + requireGlobalAdmin route
layouts); mutating controls on mixed data pages disabled via useCanMutatePlatform; rules pages
read-only on foreign tenants; additive own-tenant edit preserved.
Tests: backend 2369, MCP 252, web 469 green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Follow-up to opt-in pagination: fields= was a client-side trim in the MCP tool,
so it did not round-trip across pages — following nextLink returned the full
safe-field set again, and the shape differed page 1 vs 2+.
Move the projection server-side (matching query_raw_sessions):
- TenantConfigProjection: keep-list intersection — only the 9 non-sensitive
fields can ever be emitted; requested keys are intersected (fields=teamsWebhookUrl
yields nothing), tenantId always included. ParseFields is case-insensitive.
- TenantConfigPagination: parse fields=; BuildNextLink echoes it so the projection
carries across every page. fields is NOT in the continuation fingerprint (it is a
projection, not a filter — changing it mid-stream keeps the cursor valid).
- GetAllTenantConfigurationsFunction projects via TenantConfigProjection.ProjectAll.
- MCP list_tenants passes fields to the backend (followNextLink params) and drops the
client-side projectTenantFields; extractTenantList stays as defense-in-depth.
Now every page returns exactly the requested columns and the backend sends less over
the wire. Tests: TenantConfigProjection (incl. secret-cannot-be-requested) +
pagination fields-echo; backend 24/24, MCP vitest 52/52.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The MCP list_tenants tool fetched GET /api/config/all, which returns all
tenant configs in one unpaginated array. With ~198 tenants the projected
response exceeded the tool response cap and was offloaded to a file, making
the tool unusable for the model.
Add backward-compatible opt-in pagination to config/all (same dual-mode
pattern as GetSessionReportsFunction):
- No pageSize -> unchanged legacy bare full-config array (web + maintenance
aggregation depend on this shape; zero regression).
- pageSize -> secret-stripped { count, tenants, nextLink } envelope using
the existing ContinuationToken HMAC (scope "config-all", caller-bound),
TenantId-ordered pages. Secrets never leave the backend.
Backend: GetTenantConfigurationsPageAsync (repo + service), TenantConfigPagination
helper, dual-mode GetAllTenantConfigurationsFunction. Route/policy unchanged.
MCP: list_tenants now follows nextLink against config/all (extractTenantList as
defense-in-depth + optional fields= trim); client-side selectTenantPage removed.
Tests: TenantConfigPaginationTests 13/13; MCP security-guards 56/56.
No /api/global/ relocation here - that convention cleanup is deferred.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>