Commit Graph

642 Commits

Author SHA1 Message Date
Oliver Kieselbach bcd307d8a4 perf(backend): project index scans + bound AppInstall reads to window
Two classes of read-efficiency fixes on hot/aggregation paths. No response
shape or value changes — DTOs untouched, all 2541 backend tests pass.

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

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

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

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 16:16:03 +02:00
Oliver Kieselbach 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 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 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 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 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 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 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 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 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
Oliver Kieselbach f37ee3e6b1 perf(backend): cache Graph app token + stream-parse V2 ingest
Ingest hot path + Graph auth latency. IngestTelemetry is the #1 backend
endpoint (~35.6k/day); its p99 tail was dominated not by parsing but by an
uncached Graph token: GraphTokenService minted a fresh client-credentials
token (500-1900ms POST) on every device-validation cache miss and threw it
away. Cache it per tenant.

GraphTokenService:
- Per-tenant token cache keyed on the validated tenantId; TTL from the
  token's own expires_in minus a 5min skew margin, clamped to [60s, 55min]
  (expires_in was previously ignored). Only successful tokens are cached.
- Per-tenant SemaphoreSlim stampede gate (bounded wait) so an enrollment
  wave does not fan a burst of identical token POSTs at AAD.
- InvalidateTenant() drops the token + consent-status entries so the
  consent/Graph-permission fresh-read contract still holds.
- Per-tenant invalidation generation closes the write-after-invalidate
  race: a POST in flight when InvalidateTenant runs tags its entry with the
  pre-invalidation generation, which readers reject.

GraphFeatureDetector:
- InvalidateTenant() now also invalidates the underlying token cache, and
  applies the same generation guard to its own parsed-roles cache.
- Cross-layer order is load-bearing: invalidate the token layer FIRST, then
  bump the detector generation, so a concurrent acquire cannot pair a new
  detector generation with a stale token read.

IngestTelemetryFunction:
- Deserialize the request body directly from the buffered MemoryStream via
  JsonTextReader instead of materializing a full UTF-16 string + ToArray()
  copy (peak ~4N -> ~1N). Cap still applies before parsing; same parse
  semantics. Pure allocation hygiene on the hottest endpoint.

Tests: token caching, cross-tenant isolation, failure-not-cached, stampede
coalescing (barrier-gated), in-flight write-after-invalidate, cross-layer
invalidation, and cross-layer ordering -- each verified red without its fix.
Parse hygiene: PayloadJson stays a raw string, malformed -> 400.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 17:24:27 +02:00
Oliver Kieselbach d0486de21e perf(backend): push app-install-summary time window server-side + parallelize session join
The app dashboard and metrics endpoints loaded entire AppInstallSummaries
partitions (tenant scope) or the full table (global scope) and filtered
StartedAt >= cutoff in memory, even for days=30. The device-model join then
issued one serial GetSessionAsync point-read per distinct session.

- Add optional DateTime? sinceUtc to GetAppInstallSummariesByTenantAsync /
  GetAllAppInstallSummariesAsync: when set, a server-side StartedAt ge filter
  scopes the scan so only in-window rows are deserialized/transferred. Default
  null preserves the legacy full scan for callers that need all history.
- AppsAnalyticsHelper.LoadSummariesAsync derives sinceUtc from days; all six
  /api/apps/* functions pass it. The four metrics/app + metrics/geographic
  functions pass their existing cutoff. The in-memory Where stays as the exact
  sub-second trim (the OData filter is second-granular). MCP get_app_install_metrics
  benefits automatically (days already flows through; response shape unchanged).
- Replace the serial session point-read loop with a bounded-concurrency
  (SemaphoreSlim=10) LoadSessionLookupAsync, shared by the analytics and
  sessions builders.

AppInstallSummaries is already bounded by the session-retention cascade
(DeletionManifestBuilder) + tenant offboarding, so no separate table retention
is added. Full Backend suite: 2406 passed / 0 failed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 12:16:04 +02:00
Oliver Kieselbach 12dabbeab9 fix(backend): bound QuickSearch typeahead scan (recent window + row cap)
QuickSearchSessionsAsync walked the full SessionsIndex partition (or the
whole table for global scope) on every typeahead request, since
exact-over-fuzzy ordering forbids an early break on fuzzy-only/no-match
queries. Cost grew with the index.

Bound the scan without a new index:
- recent-window RowKey bound (RowKey lt cutoff, 90d) via the existing
  ComputeCutoffRowKeyPrefix; applies to tenant and global scope
- hard scanned-row cap (5000) as backstop for the no-early-break case

Match/ordering logic unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 11:07:29 +02:00
Oliver Kieselbach 27ef527e80 fix(backend): server-bound retention fanout read to per-run cap
The retention fanout materialized the entire eligible-session backlog every
run (GetSessionsOlderThanAsync returned a full List<SessionSummary>) but only
advanced MaxEnqueuesPerTenantPerRun=100 per tenant. A large backlog was reread
and rematerialized each run, so maintenance got slower as the backlog grew
while only 100 sessions progressed.

Cap the read at the per-run dispatch limit: GetSessionsOlderThanAsync gains a
maxResults parameter that caps maxPerPage and stops enumerating once the cap is
reached. The fanout fetches MaxEnqueuesPerTenantPerRun+1 (the +1 is a cheap
"more remaining" probe) instead of the whole backlog. Read cost is now
proportional to the cap, not the backlog; per-run progress is unchanged.

The exact eligible count is no longer known without a full scan, so the audit
now records EligibleThisRun + a MoreRemaining flag instead of the exact total.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 10:54:32 +02:00
Oliver Kieselbach 71a86a48a9 fix(backend): monitor session-deletion + tenant-offboarding poison queues
Both are self-hosted poll-loop workers (no QueueTrigger), so their poisoned
work was invisible to MaintenanceService poison-queue alerting and the
HealthCheckService health card. Add both poison siblings to
MonitoredPoisonQueues and update the watch-list guard test (4 -> 6).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 10:35:42 +02:00
Oliver Kieselbach 6e78291100 fix(backend): reject non-GUID tid before OIDC metadata fetch in auth middleware
Codex review #7 (MEDIUM): AuthenticationMiddleware read the unvalidated
`tid` claim and built a tenant-specific OIDC authority before signature
validation. A garbage tid would drive an outbound metadata fetch to a bogus
authority - a DoS/cost surface on malformed-token floods (not an auth bypass;
signature/audience/issuer are still validated afterward).

- Guard `tid` with Guid.TryParse before constructing the authority; missing/
  non-GUID throws SecurityTokenValidationException -> 401, no fetch. Loss-free:
  every legitimate Entra token (incl. consumer MSA 9188040d-...) has a GUID tid.
- Drop the now-unreachable null -> common/v2.0 fallback.
- Pass context.CancellationToken into GetConfigurationAsync (was None).

Negative cache for invalid/unreachable authorities deferred (LRU already
bounds memory; revisit only if floods observed).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 10:33:16 +02:00
Oliver Kieselbach 5ba4fb7dc9 feat(backend): retention/cleanup for previously-unbounded append-only tables
Several append-only tables had no purge mechanism and grew unbounded. Add
age-based retention to the 2h MaintenanceFunction (and the manual path) via a
new CleanupUnboundedTablesAsync orchestrator (per-table try/catch, fixed const
windows, mirroring the DistressReports pattern):

- GlobalNotifications + TenantNotifications: hybrid retention — dismissed rows
  pruned 30d after DismissedAt, any row pruned 180d after CreatedAt. Unread
  (still-actionable) admin warnings survive the dismiss window so they are never
  silently lost; the 180d catch-all bounds the table. Shared pure predicate
  NotificationRetentionFilter.BuildPredicate (unit-tested).
- HardwareRejectionNotificationTracker: 30d (FirstNotifiedAt). Pruning re-arms
  the lifetime dedup, so a model rejected again >30d later re-bells (intended).
- AuditLogs: 180d (system Timestamp, cross-partition).
- UsageMetrics: 180d (PartitionKey date-string range).
- BackupJobs: 365d (QueuedAtUtc).

PlatformStats intentionally excluded — it is a single upserted row, not
append-only. Bootstrap sessions left as-is (feature currently unused).

Each repo delete mirrors DeleteUserActivityOlderThanAsync: server-side
"X lt cutoff" filter + select PK/RK + delete loop. Deletes are resumable across
runs, so a partial run self-heals on the next cycle.

Observability: end-of-run MaintenanceLongRunning Warning OpsEvent if RunAllAsync
exceeds 10min, dual-registered in OpsAlertRulesSection.tsx, to surface a run
climbing toward the 60min functionTimeout before a future hard-abort (which
emits nothing).

Inert until backend redeploy; first post-deploy run bulk-prunes the backlog.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 17:11:28 +02:00
Oliver Kieselbach 7c1f14c66b feat(backend+web): GA "Active Users" live presence dashboard + UserActivity retention
Global-Admin cross-tenant view of web users active "now" (made an authenticated
request within a window, default 5min), backed by a new self-maintaining
UserPresence table. Not App Insights (sampling) / not SignalR.

Backend:
- UserPresence table (one row/user, PK=tenantId, RK=SHA-256(lowercase UPN));
  upserted fire-and-forget by UserPresenceMiddleware (after PolicyEnforcement,
  60s per-process throttle, UPN-gated so agent cert calls are skipped).
- GET /api/global/presence (GlobalReadOrAdmin) via GetActiveUsersFunction; read
  path propagates storage errors (5xx) instead of masking them as "0 active".
- 1-day presence retention + 90-day UserActivity retention in maintenance;
  PlatformStats.TotalUsers clamped monotonic so the public "Users Seen" figure
  cannot regress after a prune.

Web:
- /admin/presence page (live counter + table), nav entry under GA > Metrics.
- "Total Users" relabeled "Unique Users (90d)" to match the windowed metric.

Backend+web only, no ConfigVersion bump, inert until redeploy.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 19:44:30 +02:00
Claude c8ec1378ec Clarify MCP denial: tell non-whitelisted users they need to be enabled
When a user authenticated successfully but was not on the MCP whitelist,
the MCP server returned an opaque "MCP access denied" 403 — the user could
not tell why auth was interrupted or that they simply needed to be
whitelisted by the server owner.

- access-guard: distinguish a genuine authorization denial (not whitelisted)
  from an infrastructure failure (backend unreachable / malformed) via a new
  infraError flag on AccessCheckResult. Only a genuine deny gets the
  user-facing "User not enabled for MCP usage" error plus an actionable
  message naming the account and pointing at the fix (ask to be whitelisted).
  Infra failures keep a neutral "MCP access check unavailable" message so the
  user is not sent chasing the wrong fix. The reason field still echoes the
  backend reason verbatim.
- backend McpUserService: WhitelistOnly denial reason is now self-explanatory
  ("User not enabled for MCP usage (account is not on the MCP whitelist)").
- tests: cover the actionable whitelist message and that an infra failure is
  not mislabelled as a whitelist problem.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RKYavm4FAu66qnhX5nvmMT
2026-06-22 22:12:06 +00:00
Oliver Kieselbach 5cd268e88c fix(backend): derive AuthenticationMiddleware JWT-exempt set from policy catalog
The ticket-gated diagnostics/download route (PublicAnonymous, MCP session_debug)
401'd on the deployed backend: AuthenticationMiddleware kept its OWN hardcoded
_anonymousRoutes allowlist, parallel to EndpointAccessPolicyCatalog, and the new
route was added only to the catalog. Since AuthenticationMiddleware runs before
PolicyEnforcementMiddleware, the missing entry rejected the request with a JWT
401 before the PublicAnonymous policy was ever honored. Classic hand-synced
parallel-catalog drift.

Delete _anonymousRoutes / _anonymousPrefixes and derive the JWT-exempt decision
from the catalog: a route skips JWT iff its policy is PublicAnonymous or
DeviceOrBootstrapAuth (authenticated in-function via cert/bootstrap token, not
JWT). Now method-aware and fail-closed on unregistered routes. Behavior-
preserving: the old 14 routes + 1 prefix equal exactly the catalog's
PublicAnonymous u DeviceOrBootstrapAuth set.

Add drift guard in EndpointPolicyCatalogCompletenessTests:
AnonymousAndDeviceRoutes_AreJwtExemptInAuthMiddleware loops every such catalog
entry and asserts the middleware exempts it (would have caught this bug);
SkipsJwtValidation_MatchesPolicyTier locks the positive/negative tiers incl. the
diagnostics/download vs diagnostics/download-url contrast. Full suite 2393 green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 23:40:18 +02:00
Oliver Kieselbach 781cf0fb9b updated allowlist for anonymous requests 2026-06-22 23:27:05 +02:00
Oliver Kieselbach 12d67380e9 feat(mcp+backend): session_debug — client-side diag-ZIP analysis via HMAC ticket
Lets tenant-admins and GAs run end-to-end session debugging over MCP: backend
telemetry plus the agent diagnostics ZIP, downloaded and analyzed locally by the
AI client. The backend never unzips/parses the archive (no parse CPU); it only
mints a short-lived signed download ticket and proxy-streams bytes.

Backend (no ConfigVersion bump, no agent change):
- DiagnosticsDownloadTicket: HMAC codec (reuses PaginationTokenSigningKey,
  purpose-tagged diag-dl-v1, 10-min TTL) so a no-JWT client can download.
- DiagnosticsBlobStreamer: shared proxy-stream engine; DiagnosticsDownloadFunction
  (web/JWT) refactored onto it. ClassifyBlobName/BlobDestination stay put (xUnit).
- POST diagnostics/download-ticket (MemberRead, QueryParam, GA-bypass) +
  GET diagnostics/download?t= (anonymous; tenantId/blobName read only from the
  signed ticket -> no retargeting). Both added to NoStoreCacheMiddleware.

MCP:
- get_session_diagnostics tool (all roles) -> absolute downloadUrl + static zipMap
  + instructions; unwraps the {success,session} envelope.
- diag_zip_layout catalog (get_resource + protocol resource).
- debug-session prompt (tenant + GA variants).
- Tool descriptions flag the client requirement (needs local file/shell tools;
  pure chat clients cannot unzip).

Tests: backend 2380/2380 (ticket codec, NoStore, classify, catalog completeness);
MCP 261 passed (incl. envelope-unwrap regression). Inert until redeploy.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 21:29:29 +02:00
Oliver Kieselbach 2d3853c38f feat(rbac): add read-only GlobalReader platform role (backend + MCP + web)
GlobalReader = cross-tenant READ visibility like a Global Admin, but no mutating
operations and no platform settings. Provisioned exactly like a GA: a GlobalAdmins
table row with Role=GlobalReader (empty/legacy Role = GlobalAdmin, back-compat).
Semantics are ADDITIVE — a user who is also their own tenant's Admin keeps edit
rights on that tenant; a pure GlobalReader is read-only everywhere.

Backend:
- GlobalAdmins.Role column + GlobalAdminService.GetGlobalRoleAsync; IsGlobalAdminAsync
  (incl. TableAdminRepository) delegates so a GlobalReader row never reads back as admin
- RequestContext.IsGlobalReader + HasGlobalScope
- Split GlobalAdminOnly into GlobalReadOrAdmin (cross-tenant reads) vs GlobalAdminOnly
  (writes/settings) + TenantAdminOrGlobalReader for config-read
- PolicyEnforcementMiddleware: extracted testable DecideAsync seam; cross-tenant gate keys
  off HasGlobalScope; additive own-tenant role carried so IsTenantAdmin stays correct
- Config secret redaction for readers (RedactedCopyForReader) + save-time placeholder
  restore (RestoreRedactedSecretsFrom) so a redacted view can't blank real secrets
- raw table/log tools stay GlobalAdminOnly; SignalR global-admins read group admits HasGlobalScope
- /auth/me emits isGlobalReader (+ preview-gate bypass, no auto-admin); /auth/mcp emits globalRole

MCP: hasGlobalScope (GA or Reader) drives routing + tool catalog; raw secret-bearing tools
(query_table/list_tables/query_backend_logs) gated strict-GA only.

Web: AuthContext isGlobalReader + hasGlobalScope; reader reaches /dashboard + cross-tenant
views; platform Settings/Ops/Backups/Software GA-only (nav-hidden + requireGlobalAdmin route
layouts); mutating controls on mixed data pages disabled via useCanMutatePlatform; rules pages
read-only on foreign tenants; additive own-tenant edit preserved.

Tests: backend 2369, MCP 252, web 469 green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 19:07:10 +02:00
Oliver Kieselbach 2d1db6c897 feat(backend+web): generic JSON webhook provider + custom headers
Adds WebhookProviderType.GenericJson (20) so a tenant can POST a stable,
channel-agnostic JSON payload to any HTTPS endpoint - ticketing systems,
automation, or an SMTP gateway like Postal - instead of only Teams/Slack.

Backend:
- GenericJsonRenderer emits schemaVersion 1.0 with eventType, severity,
  title/summary/themeColor, primaryUrl (first openUrl action), facts[],
  sections[], actions[]; registered in WebhookNotificationService.
- NotificationAlert.EventType set on all 8 builders (enrollment_succeeded/
  _failed/_started, preprovisioning_*, hardware_rejected, sla_breach/_resolved,
  consecutive_failures, test) for consumer-side routing.
- Per-tenant WebhookCustomHeadersJson + GetGenericWebhookHeaders() (generic-only,
  restricted-header denylist, fail-soft parse) for API-key auth; dispatcher
  gained optional customHeaders applied via HttpRequestMessage; threaded into
  all 12 tenant call sites (Ops/admin-config sites unchanged).
- TableConfigRepository persists the field; UpdateTenantConfigurationFunction
  validates it (object, string values, RFC-7230 token names, CRLF-injection
  guard, 8 KB / 25-header caps).

Web:
- Generic provider option + key->value WebhookHeaderEditor (rendered only for
  provider 20); leaving the generic provider clears local headers so a later
  switch back cannot revive stale auth headers; context/types wiring sends
  headers only when provider===20; report shows Generic JSON provider name.

ConfigVersion unchanged (backend-only, no agent payload). No new
Constants.EventTypes, so no MCP catalog sync. Still fire-and-forget (no SMTP
client / retry by design). New GenericWebhookTests cover renderer, header
parsing/denylist, and save-time validation.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 14:34:53 +02:00
Oliver Kieselbach 2e3c0b8db6 feat(agent+backend+web): OOBE-console / Shift+F10 detection (default-on opt-out)
Detects a SYSTEM console opened during enrollment — the classic Shift+F10
OOBE bypass (winlogon.exe spawning cmd.exe as SYSTEM). Two complementary
Warning signals, one per-tenant toggle EnableConsoleBypassDetection
(default ON / opt-out), ConfigVersion 31 -> 32:

- oobe_console_spawned (live): ConsoleBypassWatcher + ConsoleBypassHost.
  WMI Win32_ProcessStartTrace on cmd.exe + startup probe; flags only a
  cmd.exe whose parent is winlogon.exe. PID dedup happens ONLY after a
  confirmed winlogon match, so an ignored cmd cannot suppress a later real
  one via PID reuse.
- console_prefetch_detected (startup forensic): ConsolePrefetchScanner
  (IAgentAnalyzer). A CMD.EXE-*.pf prefetch artifact whose last-run is
  after boot, covering the pre-agent OOBE window the live watcher misses.
  stat-only; corroborating artifacts sorted for a stable gate fingerprint.

Both events carry coverageComplete:false + an honest coverageNote (and the
prefetch event attribution:"coarse") — detection is best-effort, not gapless,
and only reports, never blocks.

Plumbing: Constants (2 event types) + MCP resource-catalog.ts + Web
eventTypes.ts; AnalyzerConfiguration/TenantConfiguration + GetAgentConfig
(default true) + TableConfigRepository; DefaultComponentFactory host gate +
AgentAnalyzerManager registration; full Web opt-out toggle. Tests: watcher
classification (9) + prefetch scanner (7) + analyzer-manager on/off.

Also fix(web): unrestricted-mode toggle persisted the stale pre-toggle value
because onSave() read async React state. saveConfiguration now takes an
explicit unrestrictedMode override that the toggle passes through, so the
saved value matches the UI state.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 11:29:41 +02:00
Oliver Kieselbach b71df9fc18 fix(backend): correct webhook session URL to /sessions/{id}
Webhook notifications built /session/{tenantId}/{sessionId}, which is a
dead route (404). The web app route is /sessions/{sessionId} (plural,
single segment); tenant is resolved from the session, not the path.
Fixes broken "Open session" links in enrollment-started and
success/failure notifications.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 10:55:02 +02:00
Oliver Kieselbach 8862d73eed feat(backend+web): detect pre-approved consent for rights-less admins
In larger tenants the multi-tenant app is often pre-approved by someone
with consent rights, while the admin operating our UI lacks them. The
/adminconsent redirect then fails, so ValidateAutopilotDevice/
ValidateCorporateIdentifier never persist and the tenant stays "Disabled"
even though the app + DeviceManagementServiceConfig.Read.All effectively
exist. That bool is also the hard gate in SecurityValidator, so a UI-only
badge would leave agent traffic 403'd.

Add a read-only access-check probe that verifies the concrete role in the
SP token's roles claim (via GraphFeatureDetector, not mere token
acquirability) and, when present, persists the gate bool through the
normal config PUT — opening the UI badge AND the agent gate together.

Backend:
- GraphAppPermissions.DeviceManagementServiceConfigReadAll constant.
- GET /api/config/{tenantId}/autopilot-device-validation/access-check
  (TenantAdminOrGA): InvalidateTenant + fresh GetSnapshotAsync (4s budget),
  pure ResolveAccessPresent (transient => never present), emits
  ConsentAccessDetected telemetry. Always-fresh so a propagation-time
  empty-roles snapshot can't wedge the admin out for the token TTL.

Web:
- lib/accessCheck.ts classifyAccessCheck pure fn (transient/absent never
  reconcile) + tests.
- Reconcile on consent-redirect failure (silently enables) and a per-toggle
  "Detect existing access" button for Autopilot, Corporate, and DevPrep
  Device Association (all share the same role).
- Successful consent callback now verifies the concrete role too: persists
  ONLY on authoritative "reconciled"; transient => retry/propagation
  message, absent => "permission not granted" — neither opens the gate.
- saveConfiguration returns boolean so success is shown only after a
  confirmed persist.

No ConfigVersion bump (backend + web only). Backend 2326/2326, web 464/464.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 18:41:34 +02:00
Oliver Kieselbach b515db07d7 fix(backend): enable diagnostics upload for Hosted-destination tenants
DiagnosticsUploadEnabled was gated solely on the per-tenant
DiagnosticsBlobSasUrl. Hosted-destination tenants have no such URL
(the platform owns the storage), so the agent received
DiagnosticsUploadEnabled=false and silently skipped every upload —
even with DiagnosticsUploadMode=Always. No diagnostics_collecting /
_uploaded / _upload_failed events were ever emitted.

Extract the gate into ResolveDiagnosticsUploadEnabled and enable when
a customer SAS is set OR the destination is Hosted. Config-only change,
no ConfigVersion bump; applies on next agent config poll after deploy.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 18:04:17 +02:00
Oliver Kieselbach 5775c7c176 fix(web+backend): Progress Portal live updates for end users + sidebar Dashboard gate
Two related fixes for the end-user Progress Portal experience:

Live updates / permissions: realtime/groups/join+leave were elevated to
MemberRead (regression c4dabeee), which 403'd roleless end users tracking
their own device - the page loaded initial status but never received live
eventStream pushes (looked frozen). Introduce a new EndpointPolicy tier
AuthenticatedUserWithRole that admits any authenticated caller but still
resolves IsGlobalAdmin/IsTenantAdmin/UserRole onto RequestContext, so the
SignalR functions can do per-group authorization. Real gating now lives in
SignalRAddToGroup/RemoveFromGroupFunction: tenant match, global-admins group
on GA, admin-tier notify group on TenantAdmin, member-tier notify group on
tenant membership. Plain AuthenticatedUser was rejected - it would leave the
role flags always-false and break GA/admin group joins.

Sidebar: hide the global Dashboard nav item for regular users (no
admin/operator access); they only see the Progress Portal.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 11:21:59 +02:00
Oliver Kieselbach 879bd0c59b feat(backend): optional per-tenant Entra app-role RBAC (Admin/Operator)
Grant tenant member roles via Entra ID app-role assignments on the
Enterprise App (the token's "roles" claim), in addition to the
TenantAdmins table. Per-tenant opt-in via the new GA-only
EntraAppRolesEnabled flag (backend-only, no ConfigVersion bump).

Resolution is table-first and tri-state (NotPresent/Disabled/Enabled):
an enabled table row wins, a disabled row is an explicit deny that
suppresses the claim entirely (a disabled member can never be
re-authorized through their app-role assignment), and only a missing
row falls back to the claim. Only Admin and Operator are claim-mappable
(Admin outranks Operator); Viewer and the platform GlobalAdmin role are
never claim-derived.

All effective-role consumers share one pure decision authority
(EntraAppRoleResolver.Resolve): PolicyEnforcementMiddleware, auth/me,
and feedback/status — the latter previously used a table-only check, so
Entra-only members did not see the feedback bubble.

GA toggle wired through the BootstrapToken pattern: TableConfigRepository
persistence, UpdateTenantConfigurationFunction GA-only protection, and a
checkbox in the admin tenant editor.

Tests: EntraAppRoleResolverTests (incl. disabled-row deny) + an
auto-promote regression guard; 2308/2308 backend green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 18:54:39 +02:00
Oliver Kieselbach f67341dd70 perf(backend): derive maintenance tenant list from TenantConfiguration, not a Sessions scan
GetAllTenantIdsAsync answered "which tenants exist?" by scanning the entire
Sessions table (PK projection) — an O(corpus) cost paid multiple times per 2h
maintenance run by all 6 callers (stalled/excessive sweeps, retention fanout,
platform-stats recompute, distress-report cleanup). It grows with session
history rather than tenant count.

Read the TenantConfiguration table instead (one "config" row per tenant, same
proven pattern as TableConfigRepository). Same return shape, so all callers
benefit with no interface or DI change.

Trade-off (documented in code): a fully-offboarded tenant whose config row was
deleted but whose old sessions still linger is no longer visited by maintenance
sweeps. Acceptable — the offboarding cascade removes those sessions anyway, and a
merely disabled tenant keeps its config row and is still processed.

Not bundled here: day-windowing RecomputePlatformStatsAsync — its counters are
cumulative/all-time and need separate semantic review.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 17:30:11 +02:00
Oliver Kieselbach ea81b1eca0 fix(backend): normalize V2 ingest event data to prevent DeviceSnapshot corruption
The V2 telemetry ingest path (TelemetryPayloadParser) skipped the
Newtonsoft-JToken -> native normalization the V1 NDJSON path applied. Nested
values (e.g. hardware_spec.disks/gpus) stayed as JObject/JArray and reached
System.Text.Json downstream in UpsertDeviceSnapshotAsync, serializing to corrupt
nested-empty-array output (e.g. "disks":[[[[]],[[]]]]). Verified on live data.

- Extract shared EventDataNormalizer (Normalize + NormalizeMap); both ingest
  paths and the read path (TableStorageService.DeserializeEventData) now use it,
  consolidating 3 duplicate copies of the same logic into one.
- TelemetryPayloadParser.ParseEvent now normalizes after deserialize.
- DetectHasSSD: exact Equals("SSD"/"NVMe") -> case-insensitive Contains. The
  agent reports composite media types ("NVMe SSD") so the exact compare matched
  nothing and hasSSD was false on every row, independent of the corruption.
- New EventDataNormalizationTests cover nested normalization, STJ round-trip
  safety, the read-path map, and DetectHasSSD matching.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 17:30:02 +02:00
Oliver Kieselbach e84ac248f9 feat(agent-v2): opt-in keep-awake during User-ESP phase (per-tenant, default off)
Keeps the device awake (system + display via SetThreadExecutionState) during the
User-ESP / AccountSetup phase so idle standby/sleep can't stall app installs or
account provisioning. Reboots are unaffected; the OS auto-clears the hold on
process exit, so it can never leak.

- UserEspKeepAwakeHost observes the signal rail: engage on AccountSetup phase,
  release on AccountSetupProvisioningComplete / host stop / safety cap.
- Safety cap is derived from the tenant's Intune ESP SyncFailureTimeout (+30 min
  margin), read at engage time; falls back to 90 min when unavailable. Resolved
  cap + source are reported on keep_awake_engaged for fleet observability.
- KeepAwakeController owns the hold on a dedicated thread (ES_CONTINUOUS bound to
  the thread's lifetime). Dispose guard on the engage-ready timeout path.
- Per-tenant portal toggle KeepAwakeDuringUserEsp (ConfigVersion 30 -> 31) with
  full config plumbing (Tenant/Analyzer config, table storage, agent delivery)
  and Web settings UI.
- New events keep_awake_engaged / keep_awake_released across Constants, the MCP
  resource catalog, and the Web gather-rule on_event autocomplete.
- Tests: KeepAwakeController late-confirm timeout edge, host engage/release/cap,
  and pure cap-resolution policy (V2.Core 1467 green).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 15:01:52 +02:00
Oliver Kieselbach b956de1377 feat(realmjoin): make RealmJoin watcher portal-configurable per tenant (default off)
RealmJoinWatcher was always-on, gated only by a compile-time const. Most
tenants do not deploy via RealmJoin, so the watcher and its 60-min completion
gate were pure noise. Add a per-tenant portal toggle (Agent Analyzers ->
RealmJoin Watcher), default off, delivered to the agent via remote config.

Mirrors the EnableIntegrityBypassAnalyzer flow end-to-end, with one divergence:
RealmJoin is a collector host (not an IAgentAnalyzer), so the agent-side gate
lives in DefaultComponentFactory.CreateCollectorHosts, not AgentAnalyzerManager.
When disabled the host is not created; the existing null-guard in the
DesktopArrival callback handles the absent HKU arming. The compile-time const
RealmJoinTrackingEnabled is kept as a build-time master kill-switch
(effective enable = remote flag AND const).

- Shared: TenantConfiguration.EnableRealmJoinWatcher (bool?, null=agent default
  false) + AnalyzerConfiguration.EnableRealmJoinWatcher (bool=false)
- Backend: TableConfigRepository write/read; GetAgentConfigFunction maps
  ?? false into Analyzers; ConfigVersion 29 -> 30
- Web: types + TenantConfigContext (state/load/save/reset/deps/context value) +
  SectionAgentAnalyzers + new "RealmJoin Watcher" subsection toggle

Inert until agent redeploy: old agents ignore the flag and stay always-on.
Verified: solution build 0 errors, tsc --noEmit clean, 76 backend config +
99 V2.Core tests green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 15:52:31 +02:00
Oliver Kieselbach e5bebf4bcb fix(backend): map agent_shutting_down(max_lifetime) to a terminal Failed session status
The V2 max-lifetime watchdog is deliberately "notbremse, not a session
verdict": the agent stops permanently and emits only
agent_shutting_down(reason=max_lifetime) - no enrollment_failed. Since the
backend flips session status exclusively on enrollment_complete /
enrollment_failed / esp_failure, such sessions stayed InProgress forever
(session 8bc1180f). V1 agents already emit enrollment_failed at max lifetime;
the gap is V2-only.

Both classification copies (legacy ingest + EventIngestProcessor, kept in
sync per the documented copy contract) now classify the event via a shared
IsMaxLifetimeAgentShutdown predicate and map it as the LOWEST-priority status
writer: any genuine terminal event in the same batch wins via the else-if
chain, already-terminal sessions are protected by the repository idempotency
guard, Pending (WhiteGlove) sessions are skipped, and Stalled sessions
finally resolve to Failed. failureReason carries the uptime;
failureSource=max_lifetime_watchdog.

This is the defense-in-depth half of the 8bc1180f fix - it works for every
field agent immediately on backend deploy, no agent redeploy needed. The
root-cause half (AdvisoryCompletion deadline in DecisionCore) resolves the
dead-end before max-lifetime is ever reached.

Backend tests 2284/2284 green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 13:09:17 +02:00
Oliver Kieselbach d68f6d23e0 fix(backend): cut ingest-path observability noise (stuck-warning, index 404, batch dup-rowkey)
Three sources flooded the App Insights warning/exception channels and worked
against traceability without any functional value:

1. Per-batch "still InProgress — may be stuck" warning removed from both ingest
   sites (EventIngestProcessor + IngestEventsFunction.Processing). It was
   observability-only (no status/StalledAt/OpsEvent) and fired on every ingest
   of a >4h session (~440 warnings/10h for 7 sessions). Stuck handling is owned
   authoritatively by MaintenanceService.MarkStalledSessionsAsTimedOutAsync
   (Stalled@2h silence, Failed@SessionTimeoutHours + SessionTimeouts OpsEvent),
   which acts strictly earlier than the 4h log — zero functional loss.

2. MergeSessionIndexAsync: specialize the catch on RequestFailedException 404
   (benign race — index row not created yet / awaiting dual-write backfill) and
   downgrade to LogDebug so the handled exception stops polluting the exceptions
   table. Other failures still log as Warning.

3. StoreEventsBatchAsync: dedup each chunk by RowKey ({Timestamp:ms}_{Sequence})
   before SubmitTransactionAsync. Colliding events collapse under UpsertReplace
   anyway, but Azure rejects the whole batch with InvalidDuplicateRow, forcing a
   per-event fallback and tracking a noisy TableTransactionFailedException.
   Keeping last-occurrence (matches UpsertReplace last-wins) lets the batch
   succeed cleanly.

Build: 0 warnings/errors. Tests: 123 (SessionIndex/StoreEvents/EventIngest/
Maintenance/Stalled) green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 11:23:50 +02:00
Oliver Kieselbach bc1f11d2db revert(backend): undo L4 telemetry sampling — ineffective in isolated worker
Deploy-verified on f4d51ea4: the worker-side adaptive-sampling exclusion
(EnableAdaptiveSampling=false + manual Configure<TelemetryConfiguration>
UseAdaptiveSampling(excludedTypes:"Request;Event")) does NOT take effect in the
Functions isolated worker. Live data showed the enriched worker request copy
(Source=WorkerMiddleware) sampled to ~63% while the redundant host request item
(Source="") stayed fully billed — strictly worse (lossy enriched copy, host
duplicate unchanged). host.json "Host.Results": "Error" likewise did not drop
the host request item (emitted by the host process, not governed by that level).

Restores known-good: default worker sampling (enriched copy complete at normal
traffic, burst-capped) + host.json Host.Results back to Information. L1 storage
dependency filter and L2 Function-trace suppression are unaffected and still
verified live. L4 deferred pending a mechanism verified against the isolated-
worker pipeline. H2/H3/O1/O2 from f4d51ea4 verified healthy (0 exceptions).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-09 23:17:17 +02:00