Commit Graph

1815 Commits

Author SHA1 Message Date
Oliver Kieselbach bbb2f444aa refactor(mcp-oauth): stateless signed client_id (multi-replica safe)
Replace the in-memory dynamic-client registry with a self-describing,
HMAC-signed client_id that encodes the registered redirect_uris. The
Container App is stateless with scale-to-zero and may run multiple
replicas, so the old Map was wiped on cold start and never shared across
replicas -- a client_id minted by /oauth/register read as unknown at
/oauth/authorize, surfacing as invalid_client.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 12:22:30 +02:00
Oliver Kieselbach 4dddce6326 fix(global-reader): treat GlobalReader exactly like a GA in the sidebar (read-only aside)
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>
2026-06-29 12:18:44 +02:00
Oliver Kieselbach a41715105d fix(global-reader): show platform nav on global scope regardless of the Global View toggle
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>
2026-06-29 11:47:19 +02:00
Oliver Kieselbach 7742113b29 fix(delegated): pass ?tenantId= on dashboard session links so delegated detail drill-in resolves
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>
2026-06-29 10:43:09 +02:00
Oliver Kieselbach 4c6e63cefd changed default Audit view 2026-06-28 14:23:46 +02:00
Oliver Kieselbach 3a4291336a Merge pull request #79 from okieselbach/feature/delegated-scoped-global-reader
feat(delegated): scoped-global reader (MSP Phase 7) + security hardening
2026-06-26 23:28:11 +02:00
Oliver Kieselbach 9f367cea0c fix(delegated): exclude platform-operational reads from delegated scope (least-privilege)
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>
2026-06-26 23:21:39 +02:00
Oliver Kieselbach 8871259446 fix(delegated): MCP Disabled-row kill-switch + web tenant-bound defense-in-depth
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>
2026-06-26 23:21:19 +02:00
Oliver Kieselbach 15cd5fd140 fix(delegated): bind SignalR notify-group authz to the group tenant (home-role escalation)
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>
2026-06-26 19:38:00 +02:00
Oliver Kieselbach 51c0987b00 fix(delegated): enforce allow-list on the single-tenant drill (home-tenant escalation)
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>
2026-06-26 19:13:36 +02:00
Oliver Kieselbach aad3522dab fix(delegated): bound the aggregate fallback + correct override-page banner subtitle
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>
2026-06-26 18:59:02 +02:00
Oliver Kieselbach aa2e066055 feat(delegated): scoped audit log + geographic session drill-in
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>
2026-06-26 18:20:48 +02:00
Oliver Kieselbach 8dd45b20fb feat(delegated): scoped-global reader — dashboard + analytics + fleet overview
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>
2026-06-26 18:06:25 +02:00
Oliver Kieselbach 250560ce25 reduced auth cache timing alignments 2026-06-26 13:18:58 +02:00
Oliver Kieselbach 3f88f88a0c reduced auth caching times 2026-06-26 13:11:14 +02:00
Oliver Kieselbach ec3f6e931f feat(web): device name in session-detail browser tab title
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>
2026-06-26 12:50:23 +02:00
Oliver Kieselbach a5075b2236 feat(mcp): delegated-admin (MSP) support over MCP — tenant-bounded cross-tenant reads
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>
2026-06-26 10:54:45 +02:00
Oliver Kieselbach 0ad08d8185 Merge pull request #78 from okieselbach/feature/delegated-admin-msp-mode
MSP Mode / Delegated Admins: scoped cross-tenant read access (Phases 0-5)
2026-06-26 01:01:44 +02:00
Oliver Kieselbach 8505309980 fix(web): session-suggestion click respects sessionLinkTarget [Phase 5 follow-up]
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>
2026-06-26 00:39:22 +02:00
Oliver Kieselbach 104a56a07b feat(web): fleet drill-in reuses the dashboard session browser [Phase 5 follow-up]
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>
2026-06-26 00:22:46 +02:00
Oliver Kieselbach 106f075216 feat(web): fleet session-list pagination + dedupe SessionStatusBadge [Phase 5 follow-up]
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>
2026-06-26 00:08:53 +02:00
Oliver Kieselbach 104c70ae58 feat(web): MSP session-detail drill-in for managed tenants [Phase 5]
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>
2026-06-26 00:01:50 +02:00
Oliver Kieselbach b2abe0e74a feat(web+backend): delegated-admin (MSP) grant/revoke management [Phase 4]
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>
2026-06-25 23:41:33 +02:00
Oliver Kieselbach 82184efbe7 fix(web): clear session rows on tenant switch in fleet drill-in [Phase 3d follow-up]
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>
2026-06-25 23:18:33 +02:00
Oliver Kieselbach 7dd3f19d9e feat(web): fleet tenant drill-in (switcher) for MSP admins [Phase 3d]
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>
2026-06-25 23:04:58 +02:00
Oliver Kieselbach 8192ff1aa8 fix(web): fleet roll-up success rate is terminal-only, matching the cards [Phase 3c follow-up]
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>
2026-06-25 22:36:41 +02:00
Oliver Kieselbach 7c2b5d10cb feat(web): per-tenant health + roll-up on the fleet overview [Phase 3c]
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>
2026-06-25 22:24:33 +02:00
Oliver Kieselbach e2ce43a2d0 fix(web): no broken Dashboard nav path for delegated-only MSP users [Phase 3b follow-up]
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>
2026-06-25 22:14:49 +02:00
Oliver Kieselbach 23f554f5ac feat(web): fleet area gate + nav + landing grid for MSP admins [Phase 3b]
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>
2026-06-25 22:06:03 +02:00
Oliver Kieselbach 261d5df5e1 fix(backend): don't onboard MSP home tenant on delegated-only login [Phase 3a follow-up]
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>
2026-06-25 21:49:53 +02:00
Oliver Kieselbach ab9b3d6211 feat(web+backend): surface delegated (MSP) scope on /auth/me + AuthContext [Phase 3a]
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>
2026-06-25 21:34:01 +02:00
Oliver Kieselbach a4f071a836 fix(bootstrap): correct scalar-unwrap in OOBE-relax profile age check
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>
2026-06-25 21:20:35 +02:00
Oliver Kieselbach 588ef08fdf fix(bootstrap): correct scalar-unwrap in OOBE-relax profile age check
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>
2026-06-25 21:04:49 +02:00
Oliver Kieselbach 4bb6dd59f7 test(backend): cover delegated config/all subset + redaction seams [Phase 2b follow-up]
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>
2026-06-25 20:58:11 +02:00
Oliver Kieselbach 43a87ff747 perf(backend): read-bound delegated config/all to point-reads [Phase 2b follow-up]
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>
2026-06-25 20:53:38 +02:00
Oliver Kieselbach 87711e1261 feat(backend): bounded tenant list for delegated admins (MSP fleet) [Phase 2b]
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>
2026-06-25 20:40:50 +02:00
Oliver Kieselbach d44dbe7ec0 feat(backend): delegated single-tenant access to /api/global/* reads (MSP switcher) [Phase 2a]
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>
2026-06-25 20:16:49 +02:00
Oliver Kieselbach 967faf47ee fix(backend): side-effect-free role resolution in auth middleware [Phase 1 follow-up]
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>
2026-06-25 19:50:37 +02:00
Oliver Kieselbach e1a40c52c3 feat(backend): delegated-admin cross-tenant gate (MSP mode) [Phase 1]
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>
2026-06-25 19:34:39 +02:00
Oliver Kieselbach 05a591e1d4 feat(backend): delegated-admin (MSP mode) data model + scope service [Phase 0]
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>
2026-06-25 18:50:56 +02:00