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>
useTenantSessions kept the previous tenant's sessions array when tenantId
changed (only flipping loading), so navigating managed tenant A -> B briefly
rendered A's recent sessions under B's heading until B's request landed. Both
tenants are in scope (no authorization leak) but it's misleading in the MSP
drill-in. Reset to an empty list at the start of every new tenant fetch.
tsc clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Make the fleet cards navigable into a per-tenant read-only overview at
/fleet/[tenantId]: header, stat tiles (active / sessions / failed / terminal-
only success rate) and a recent-session list. The tenant is an explicit route
param, so every read is a single-tenant /global/*?tenantId= call the backend
bounds to the caller's delegated scope (GlobalReadOrDelegatedSubset) - there is
no all-tenants path. As defense in depth the page refuses a tenantId the caller
does not manage (unless full platform scope) BEFORE issuing any fetch.
- app/fleet/[tenantId]/page.tsx: guarded drill-in; reuses useFleetSummaries for
the stat tiles (single-tenant) and a new useTenantSessions hook for the list.
- app/fleet/[tenantId]/hooks/useTenantSessions.ts: first-page session fetch via
globalSessions.list(tenantId); fail-soft, em-dash on error.
- components/SessionStatusBadge.tsx + utils/sessionFormat.ts: shared read-only
presentational primitives (new code consumes them; the existing inline copies
in dashboard/geographic are left untouched this phase - migration is a tidy-up
follow-up).
- app/fleet/page.tsx: FleetCard is now a Link to /fleet/[tenantId].
Deep session-detail drill-in is intentionally out of scope: it needs a
/api/global/sessions/{id} (+ /events) endpoint, since the tenant-scoped
/api/sessions/{id} detail path is MemberRead-only and unreachable for a
delegated, non-member caller. Rows are non-navigable until then.
tsc clean; 481/481 web tests green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Addresses a Codex finding: the per-tenant cards show the backend success rate,
which is terminal-only (succeeded / succeeded+failed). The fleet roll-up instead
divided by total sessions (which includes active/pending), so the tile and the
cards disagreed — e.g. a tenant with 90 succeeded / 10 failed / 900 active shows
90% on its card but the roll-up showed 9%.
- computeFleetRollup: success rate now sum(succeeded) / (sum(succeeded)+sum(failed)),
excluding active/pending from the denominator — same definition as DashboardStats.
- successTone: red emphasis now gates on terminal-session count (not total), so a
tenant with only active sessions reads 0% but isn't flagged as failing.
- Tests: replaced the total-based weighted test with terminal-only weighting and
added an explicit 90/10/900 case locking in 90% (not 9%).
Web tsc clean; 481 web tests green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Enriches the fleet grid with live health, so an MSP admin can triage "which of my
customers is burning" at a glance.
- useFleetSummaries: bounded (concurrency 6) subset fan-out over the Phase-2a
single-tenant /global/stats/sessions?tenantId= endpoint — one server-aggregated
summary per managed tenant, collected into a map. Client-orchestrated over the
already-bounded per-tenant reads; one tenant's failure never sinks the view.
- fleetRollup: pure, testable roll-up. Success rate is WEIGHTED by session volume
(sum succeeded / sum total), not an average of per-tenant rates. 4 unit tests.
- page: roll-up tiles (tenants / active / failed / weighted success) + per-tenant
cards showing active, failed, success%. Cards are triage-ordered (most failures
first; loading tenants last) and use red emphasis on failures / sub-80% success.
Web tsc clean; 480 web tests green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Addresses a Codex finding: Phase 3b made delegated-only MSP users non-regular (so
they get the group nav), which also rendered the Dashboard link for them — but
/dashboard classifies the same user as regular and bounced them to /progress. So
clicking Dashboard sent an MSP admin to the end-user progress portal.
Fixed on both ends:
- GlobalSidebar: gate the Dashboard link on isAdminOrOperator || hasGlobalScope
(genuine own-tenant/platform users), not merely !isRegularUser. A delegated-only
admin has no own-tenant stake, so the link is hidden; their entry point is the
Fleet group.
- /dashboard: a delegated-only user who reaches it by deep link now redirects to
/fleet instead of /progress, consistent with the AuthGate landing.
Web tsc clean; 476 web tests green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Gives a delegated ("MSP") admin a place to land and see the tenants they manage.
- ProtectedRoute: new requireFleetScope (full platform scope OR delegated). The
backend still enforces the per-tenant bound on every request; this just keeps a
single-tenant user off the fleet route.
- Nav: a "Fleet" group (Managed Tenants -> /fleet) with a new "fleet" visibility,
shown to a delegated admin who lacks full platform scope (a GA/Reader manages
tenants through the richer Global Admin section, so it stays hidden for them).
GlobalSidebar's isRegularUser now accounts for fleet scope, so a delegated user
gets the group-filtered nav instead of the minimal regular-user list.
- AuthGate: a delegated-only user (no own-tenant/platform role) lands on /fleet.
- app/fleet: layout (requireFleetScope) + landing page rendering the managed-tenant
grid. The list comes from config/all (backend-bounded to the caller's subset,
Phase 2b) and is additionally intersected with the JWT delegatedTenantIds as
client-side defense in depth.
Cards are read-only overview this phase; per-tenant health (3c) and drill-in (3d)
build on this. Web tsc clean; 476 web tests green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Addresses two linked Codex findings created by the Phase-3a preview-gate bypass.
(1) HIGH — auto-admin escalation: the first-login auto-admin rule excluded only
GlobalReader, not delegated callers. An external MSP user delegated to manage
tenant B, logging into a home tenant A with no existing admins, would be silently
written as Tenant Admin of A — converting a read-only delegated assignment into
write authority over the MSP home tenant. Fix: needsAutoAdmin also excludes
delegated callers (!isDelegated). A delegated user who legitimately administers
their own tenant has a TenantAdmins row, so this only suppresses the silent grant.
Regression test added (delegatedTenantIds non-empty, memberRole null,
hasTenantAdmins false → NeedsAutoAdmin false, isTenantAdmin false, role null).
(2) side-effect class: /auth/me read the home-tenant config via the auto-creating
GetConfigurationAsync and then ran first-login onboarding side-effects (domain /
OnboardedBy write, "new tenant signup" Telegram, auto-re-enable). For a
delegated-only external MSP login this phantom-onboarded the home tenant. Fix:
read via the non-creating TryGetConfigurationAsync, and run the onboarding
side-effects only for a genuine home-tenant participant (not delegated-only, OR
global, OR an existing member). Genuine first-users still onboard normally.
Full Functions suite green (2473).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Foundation for the web MSP/fleet experience: the app now knows whether the signed-in
user is a delegated admin and which tenants they manage.
Backend (/api/auth/me):
- Resolve the caller's delegated scope (cached) alongside the existing parallel
fetches and return delegatedTenantIds (the managed tenant set) + isDelegated.
- Delegated admins bypass the private-preview gate, exactly like Global Admin /
Reader — an MSP whose own home tenant is not on the waitlist must still be able to
log in and manage its authorized tenants. Gate 1 (suspended home tenant) still
takes precedence. BuildAuthResult gains an optional delegatedTenantIds param
(existing call sites unaffected); 3 new tests + the side-effect harness updated.
Web (AuthContext):
- UserInfo gains isDelegated + delegatedTenantIds; AuthContextType gains
hasFleetScope = hasGlobalScope || isDelegated for fleet/switcher gating. All
/auth/me parse paths (success, prefetch, preview/error fallbacks) populate them.
Full backend suite green (2472); web tsc clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The $profilePaths assignment wrapped only the inner subexpression in @(),
so the trailing Where/Select-Object -Unique pipeline unwrapped a single
result back to a scalar string. With exactly one user profile present,
$profilePaths[0] then indexed into the string and returned its first
char ('C'), making Get-Item fail with "Cannot find path 'C'". The OOBE
age check crashed and the Windows Backup for Organizations restore case
never relaxed guards 2+3.
Force an outer @() so $profilePaths is always an array. Also surface the
profile age and the exact failing condition in the SKIP/NOT-applied logs
for diagnosing future OOBE attempts.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The $profilePaths assignment wrapped only the inner subexpression in @(),
so the trailing Where/Select-Object -Unique pipeline unwrapped a single
result back to a scalar string. With exactly one user profile present,
$profilePaths[0] then indexed into the string and returned its first
char ('C'), making Get-Item fail with "Cannot find path 'C'". The OOBE
age check crashed and the Windows Backup for Organizations restore case
never relaxed guards 2+3.
Force an outer @() so $profilePaths is always an array. Also surface the
profile age and the exact failing condition in the SKIP/NOT-applied logs
for diagnosing future OOBE attempts.
Co-Authored-By: Claude Opus 4.8 (1M context) <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>
Lets a delegated ("MSP") admin read its managed tenants through the cross-tenant
/api/global/* endpoints — but ONLY on the single-tenant ?tenantId=X path, bounded
by the middleware to a tenant in its allowed set. This is what makes the tenant
SWITCHER work end-to-end (drill into a managed tenant, see its normal dashboard).
The dangerous no-tenantId AGGREGATE path (fan-out over ALL tenants) stays
GA/Reader-only and is untouched here; bounded fleet aggregation is a separate step.
- Middleware: GlobalReadOrAdmin added to the delegated read tier. The existing
`isScopedRoute && crossTenant` guard means a delegated grant still fires ONLY on
a tenant-scoped route for an in-scope target — so a delegated caller reaches a
global endpoint only via its single-tenant path, never the aggregate path.
- Catalog: the 27 GlobalReadOrAdmin routes whose handlers strictly restrict to the
named ?tenantId= (verified by an exhaustive per-endpoint audit) recataloged
None -> QueryParam. QueryParam/RouteParam on a GlobalReadOrAdmin route now
formally means "handler restricts to the named tenant => delegated-reachable";
aggregate-only routes (presence, platform metrics, distress-reports, config/all,
…) stay None and remain unreachable by delegated callers. Documented inline.
- Tests: delegated single-tenant global read allowed in-scope / denied out-of-scope
/ denied on the no-tenantId aggregate path; an aggregate-only route stays blocked
even with an in-scope ?tenantId=; Global Reader aggregate path regression; and a
catalog-contract theory pinning the SAFE(QueryParam) vs AGGREGATE-ONLY(None) split.
Updated GlobalAppsRoutes completeness assertion to QueryParam. Full suite green (2461).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Addresses a Codex review finding: the normal policy evaluator runs before the
delegated rescue, so an external MSP user's first delegated cross-tenant read
still touches the own-home-tenant role path. That path called
TenantConfigurationService.GetConfigurationAsync, which AUTO-CREATES and persists
a default TenantConfiguration row for a missing tenant — writing a phantom config
row for the MSP's (possibly non-onboarded) home tenant.
Authorization role resolution must be side-effect-free. ResolveEffectiveRoleAsync
now uses the existing non-creating TryGetConfigurationAsync (returns the in-memory
default without persisting). A missing config means EntraAppRolesEnabled = false
either way, so the resolved role is unchanged — only the phantom write is gone.
This also removes the same latent write for ANY authenticated non-member.
Regression test: "delegated read does not create config for MSP home tenant"
(verifies SaveTenantConfigurationAsync is never called). Full Functions suite green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Wires the delegated ("scoped global" / MSP) tier into the auth middleware. A
delegated admin signs into their OWN home tenant but may now READ a SUBSET of
tenants — exactly the tenants in its DelegatedAdmins scope — without holding
full platform (GlobalAdmin/GlobalReader) scope.
Mechanism (PolicyEnforcementMiddleware.DecideAsync):
- Resolve the target tenant up-front (ResolveTarget), then resolve delegated
scope LAZILY — only when the caller lacks full platform scope AND the route is
a cross-tenant, tenant-scoped route. Keeps the common authenticated path free
of the extra (cached) table read. Service errors still fail closed to 503.
- A delegated grant applies ONLY to read tiers {MemberRead, TenantAdminOrGlobalReader}
on RouteParam/QueryParam routes, and ONLY for a target in the caller's scope.
It rescues a tier-denial for a delegated-only caller and lifts the cross-tenant
block for that target. Write tiers never admit a delegated caller (even a
DelegatedAdmin row grants read-only this phase), so no read->write escalation.
- Aggregate /api/global/* endpoints (GlobalReadOrAdmin) fan out over ALL tenants
and are deliberately NOT reachable by delegated callers until Phase 2 bounds
them to AllowedTenantIds.
RequestContext gains IsDelegatedReader/IsDelegatedAdmin/IsDelegated, HasFleetScope
and AllowedTenantIds (the delegated subset, for Phase 2 aggregation).
Secret-leak hardening: GetTenantConfigurationFunction now redacts by default —
only a Global Admin or the tenant's OWN admin sees unredacted secrets
(CanViewSecrets, extracted as a testable static). Behavior is identical for all
pre-existing roles; it closes the path where a cross-tenant delegated/global
reader would otherwise receive SAS/webhook secrets.
12 new tests (delegated allow/deny matrix + CanViewSecrets clearance). Full
Functions suite green aside from two pre-existing timing-flaky tests (unrelated
code, pass in isolation).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Foundation for "MSP mode" — a scoped-global authorization tier between a
single-tenant member and a platform GlobalAdmin. A delegated admin may access a
SUBSET of tenants (its assignment rows), resolved UPN-based and tid-agnostic,
exactly like GlobalAdmins.
Phase 0 is purely additive: the service is registered but no auth path consults
it yet (the cross-tenant gate change lands in Phase 1), so behavior is unchanged.
- Constants: DelegatedRoles/DelegatedStatus/DelegatedSource; new DelegatedAdmins
table added to TableNames.All (auto-create), CriticalBackupTables.All and
AuthTablesFullRestoreForbidden (identity table — full-table restore blocked).
- IAdminRepository + TableAdminRepository: DelegatedAdminEntry DTO + CRUD
(PK=UPN, RK=TenantId). Hot-path GetDelegatedTenantsAsync (partition scan) and
reverse GetDelegatedAssigneesAsync. Queries use the typed predicate overload
(no string-interpolated OData filter — avoids filter injection).
- DelegatedAdminService: resolves DelegatedScope (Covers/RoleFor/CanWrite) with a
5-min cache + write-through invalidation. Fail-closed: only Active+enabled rows
with a recognized role confer scope; empty role -> DelegatedReader; unknown -> dropped.
- 16 unit tests (fake repo); full Functions suite 2434/2434 green. Backup
catalog-count assertion bumped 15 -> 16.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>