Commit Graph

136 Commits

Author SHA1 Message Date
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 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 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 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 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 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 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
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 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 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 2a5f9e133e feat(backend): enrich request telemetry (real client IP, tenant, 429, distress IP)
Makes the requests table actually usable for diagnosing agent traffic and rate
limits, without adding any new telemetry items — only properties on the already-
emitted, sampling-excluded Request telemetry (host.json excludedTypes "Request;
Event"), consistent with StorageDependencyFilterProcessor.

RequestTelemetryMiddleware:
- client_IP: the isolated worker behind Front Door/App Service left it 0.0.0.0.
  Set Context.Location.Ip from X-Azure-ClientIP (Front Door's true client egress
  IP) else trusted rightmost X-Forwarded-For. Header values are joined via
  StringValues.ToString() so the rightmost trusted hop wins (not a spoofable
  left entry). Diagnostic only — the rate limiter independently uses the trusted
  hop, so a spoofed value here cannot affect throttling.
- TenantId: agent ingest skips PolicyEnforcementMiddleware so reqCtx.TenantId was
  empty. Fall back to X-Tenant-Id, but ONLY on an exact allowlist of device
  endpoints that actually validate it (agent telemetry/ingest/error/distress,
  bootstrap ingest/error) and only when it is a well-formed GUID — a prefix match
  would let anonymous/management routes (bootstrap/validate, bootstrap/sessions,
  agent/config, …) be tagged with a spoofed tenant.

RateLimitService: 429 warning is now structured ({RequestsInWindow}/{MaxRequests}
/{WindowSeconds}/{RateLimitKeyPrefix}) so it's queryable; privacy-safe (8-char
prefix only — the key may be a cert thumbprint, bootstrap token, or UPN).

ReportDistressFunction: store the real device egress IP in SourceIp (table entry
+ AgentDistressReport event) via new ClientIpExtractor.GetClientEgressIp; the
rate-limit key keeps the un-spoofable trusted hop.

Full backend suite 2249/2249 green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 18:28:08 +02:00
Oliver Kieselbach 5586963bcc feat(autopilot): show "not registered" devices (14-day window) under enrollment validation
Adds a tenant-scoped, informational overview of devices that failed enrollment
with HTTP 403 because they were not in the tenant's Autopilot / Corporate
Identifier registry. Mirrors the Hardware Whitelist rejection insights but
aggregates per device (serial number), matching how Autopilot registers
individual devices rather than models. Read-only, no webhook.

Backend
- GetDeviceNotRegisteredFunction: route audit/device-not-registered, MemberRead,
  filters distress reports on ErrorType == "DeviceNotRegistered", aggregates by
  serial (case-insensitive; empty serial -> single bucket); 14-day horizon is
  the existing distress-report retention.
- Register route in EndpointAccessPolicyCatalog (fail-closed).
- 8 unit tests for BuildAggregatedResult.

Frontend
- api.distress.deviceNotRegistered().
- NotRegisteredDevicesInsights component (serial/mfr/model/attempts/first/last
  seen, green empty-state, unverified-data notice).
- Wired below AutopilotValidationSection.

DeviceNotRegistered (403) covers both Autopilot and Corporate Identifier
validation; the agent cannot distinguish which failed, so copy is neutral.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 13:15:23 +02:00
Oliver Kieselbach dbb4b3f5a7 feat(metrics): server-side Fleet Health aggregation endpoints
Fleet Health previously drained up to 200k raw sessions into the browser
(MAX_EAGER_PAGES=200 x 1000) over as many HTTP round-trips, then computed
7 aggregates in useMemo on the main thread. Move that aggregation to the
backend, mirroring the dashboard-stats server-aggregation path.

- MetricsMath.BuildFleetHealthPayload: single pass over the windowed
  session list producing stats, daily timeline, top failure reasons, and
  model health/slowest/top-failing breakdowns. Semantics ported verbatim
  from the client (success rate over ALL sessions; avg duration over
  non-in-progress sessions incl. failures), so displayed numbers are
  unchanged. Typed FleetHealthMetrics DTO in Shared for a typed contract.
- GET /api/metrics/fleet-health (MemberRead) and
  GET /api/global/metrics/fleet-health (GlobalAdminOnly, optional
  ?tenantId=) reuse the existing ISessionRepository window readers.
- Registered both routes in EndpointAccessPolicyCatalog (fail-closed).
- 9 xUnit tests covering the aggregation semantics.

Frontend swap to these endpoints lands in a follow-up commit. App-metrics
(slowest/failing apps) is already backend-aggregated and untouched.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 14:32:44 +02:00
Oliver Kieselbach fed162946e feat(metrics+mcp): app-install & software-inventory MCP tools + DO rollup
Backend:
- Extract MetricsMath.BuildAppMetricsPayload as the single source for the
  app-metrics aggregation (was duplicated verbatim across the tenant and
  global functions) and add a Delivery Optimization rollup: per-app peer/MCC
  offload bytes + overall deliveryOptimization summary (peers and MCC counted
  separately, peerOffloadPercent credits both as not-from-CDN).
- New tenant-scoped GetTenantSoftwareInventoryFunction (GET metrics/software-
  inventory, MemberRead, JWT tenant) + fail-closed policy-catalog entry.

MCP server:
- get_app_install_metrics: role-aware routing (global/metrics/app vs
  metrics/app), surfaces the DO rollup.
- get_software_inventory: one role-aware tool; non-GA sees only its own tenant
  (no tenantId/scope in schema), GA additionally gets tenantId + scope=unmatched.
  New exported paginateInventory client-side pager.

Tests: MetricsMathAppPayloadTests (DO rollup/offload/sample-floor) + vitest
paginateInventory. Backend 2202/2202, MCP 220 passed, tsc clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 17:51:36 +02:00
Oliver Kieselbach 0090790350 feat(health): non-blocking MCP card with wake-aware cold-start handling
The MCP server health probe previously sat inside PerformAllChecksAsync's
Task.WhenAll, so /health/detailed blocked on the MCP timeout before returning
any card, and a scaled-to-zero cold start was reported as an immediate warning.

Backend:
- Move the MCP probe out of the blocking all-checks batch onto its own
  GET /api/health/mcp endpoint (AuthenticatedUser; visible to all, not GA-only).
- CheckMcpServerAsync now uses a wake-capable timeout (McpServerHealthTimeoutSeconds,
  default 30s). Azure Container Apps holds the request during cold-start activation,
  so the probe doubles as the wake signal: a successful wake -> healthy ("woke from
  idle" when >2.5s), only a failed wake -> warning, a reachable-but-erroring server
  (non-2xx) -> unhealthy.
- Register health/mcp in EndpointAccessPolicyCatalog (fail-closed).

Frontend:
- Fetch the MCP check on its own track, concurrently with and independent of the
  fast checks. The card renders a blue "checking..." state immediately and updates
  in place when the probe resolves; the other cards never wait on it.
- The MCP result folds into the overall banner/counts only once resolved, so a slow
  cold start no longer flips the page to "Warnings detected".

Test: warning-path assertion updated for the new wording (10 HealthCheckService
tests green; 61 policy/endpoint tests green).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 16:16:35 +02:00
Oliver Kieselbach d72a79b156 chore(backend): remove dead /raw/events/search endpoint + orphaned repo methods
After the MCP search tools moved to the paginated /api/raw/events fan-out
(prev commit), /api/raw/events/search had zero callers anywhere in the repo
(no MCP tool, no web). Remove the now-dead surface end to end:

- Delete SearchEventsFunction (raw/events/search + global/raw/events/search).
- Drop both routes from EndpointAccessPolicyCatalog and the no-store cache
  middleware allowlist (+ its test InlineData). EndpointPolicyCatalog
  completeness test stays green (route and entry removed together).
- Remove the now-orphaned repo method SearchEventsByTypesAsync (its only
  caller was the deleted endpoint) from ISessionRepository + TableSessionRepository
  delegation + TableStorageService impl.
- Remove the non-paginated SearchSessionsByEventAsync: its only runtime caller
  was SearchEventsByTypesAsync, and it is strictly redundant with the resumable
  SearchSessionsByEventPageAsync (used by /api/raw/events + search/sessions-by-event).
  Reword the surviving paged method's xmldoc/comment that referenced it.

No capability lost: multi-type cross-session event search still runs via the
MCP fan-out over the paginated single-type endpoint; single-type primitives
remain exposed via query_raw_events + search_sessions_by_event. Full solution
builds clean (0 errors); catalog-completeness + cache-middleware tests 77/77.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 15:25:26 +02:00
Oliver Kieselbach 0734484f0e feat(mcp): harden MCP server + add list_tenants & fleet vulnerability summary
MCP server review follow-ups (PR A-D):
- Remove stale agents/query.ts dev script + drop unused @anthropic-ai/sdk dep
- Add 256kb body limit on /mcp; server-level instructions + 3 diagnostic prompts
  (investigate-failed-session, cve-exposure-audit, compare-agent-versions)
- New list_tenants tool over /api/config/all with a strict keep-list projection
  so tenant secrets (webhook/SAS URLs) never reach the model (+ 6 unit tests)
- New get_vulnerability_summary tool + backend GET /api/{global/}metrics/vulnerability
  aggregating the CveIndex (top CVEs, severity breakdown, KEV, affected devices;
  truncated flag on scan cap) + 6 aggregator unit tests
- Test hygiene: exclude tests from tsc output, pin vitest to src/ (no dist double-run)
- Bump MCP server version 1.2.0 -> 1.3.0

Backend solution builds clean; MCP 52 unit tests + 6 backend aggregator tests green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 17:56:22 +02:00
Oliver Kieselbach a5ce72fbf5 added vulnerability counters 2026-05-27 14:19:26 +02:00
Oliver Kieselbach 9e689dc9d8 feat(backend/backup): PR2 — single-row restore + admin UI
POST /api/global/backups/{backupId}/restore-row (GA-only) with preview|commit
modes. Preview scans the ETag-pinned NDJSON blob for the requested (pk, rk),
diffs against the live row, and returns rowSha256 + currentETag for echo.
Commit re-validates everything under a short maintenance lease and writes via
UpdateEntity(IfMatch=etag, Replace) or AddEntity; 412/404/409 → CurrentRowChanged.

Backend:
- RestoreTablePreflightValidator (no I/O) + BackupRestoreInputValidator (manifest
  parse, entry check, BlobName canonical, ETag-pin + SHA in one stream read)
- CriticalTableRestoreService with PreviewRowAsync (no lease) + CommitRowAsync
  (MaintenanceLeaseHolder, 45 s renewal, deterministic finally release)
- BlobBackupStore.OpenNdjsonReadAsync — GetProperties + OpenRead(IfMatch=etag)
- BackupTerminalException carries a stable Code for HTTP envelope mapping
- OpsEventService.RecordBackupRowRestoredAsync (Warning; payload is
  container + blobName only, never a SAS URL)
- 27 new xUnit tests; full backend suite 1979/1979 green

Web:
- /admin/backups list page with newest-first list + "Run backup now" trigger
  and async job-status poller
- /admin/backups/{backupId} detail page with per-table breakdown + single-row
  restore trigger
- RestoreRowDiffModal with Added/Removed/Changed/Unchanged pills and
  auth-table security banner (GlobalAdmins/TenantAdmins/McpUsers)
- api.backups namespace + matching DTO types
- globalNavConfig: ga-backups under Ops
- BackupRowRestored registered in OPS_EVENT_TYPES (dual-register policy)
- 6 new vitest cases; full web suite 390/390 green

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 16:33:46 +02:00
Oliver Kieselbach d4034842ee feat(backend/backup): PR1 — critical-table backup feature (daily timer + manual GA trigger)
Adds the full backup pipeline that snapshots 15 critical tables (Admin/Tenant
config, rules, identities, audit, preview) into a logically-isolated blob
container, daily at 04:00 UTC and on operator demand. Restore endpoints follow
in PR2/PR3.

Architecture:
* Daily TimerTrigger + manual POST /api/global/backups/trigger (GA-only,
  fail-hard producer). HTTP returns 202 + jobId on enqueue, 500 if the queue
  write fails (and the BackupJobs row is rolled to Failed).
* BackgroundService queue worker (not QueueTrigger — matches the Analyze/
  IndexReconcile pattern that avoids the Functions-host connection-binding
  app-setting). Worker holds the maintenance lease for the whole run; service
  is intentionally lease-unaware so it stays unit-testable.
* BlobBackupStore wraps the new 'critical-table-backups' container with a
  shared maintenance-lease sentinel at '_lock/maintenance.lock' that
  serialises every backup or restore op across worker/timer/single-row paths.
* CriticalTableBackupService streams each table through CryptoStream(SHA256)
  into per-table NDJSON blobs, then writes manifest.json LAST as the
  durability anchor. Per-table failures degrade Outcome to Partial without
  aborting; per-run budget (50 min) → Skipped status.
* BackupJobsRepository persists the six-state machine
  (Queued/Running/Completed/Failed/Skipped/BlockedTerminal) with ETag-CAS
  updates. Enum→string mapping rejects unknown values on read so refactors
  surface loudly instead of silently shifting state.
* BackupJobWatchdog (30 min timer) heals stuck rows via a probe lease on the
  shared sentinel: Queued > 60 min → Failed, Running with stale heartbeat
  AND free lease → Failed (lease-busy means a live worker — leave alone).
* OpsEvents: distinct CriticalTableBackupPartial (Warning) vs Failed (Error)
  so operators can wire softer Telegram rules for partial captures.
* MaintenanceService.MonitoredPoisonQueues + OPS_EVENT_TYPES catalog
  registered (dual-register memory).

Test coverage (15 new tests, 1952/1952 backend xUnit green):
* BackupManifestJsonTests: enum-as-string (allowIntegerValues=false rejects
  numeric values), DictionaryKeyPolicy unset (Azure column names preserved
  case-faithfully), manifest field round-trip.
* BackupJobsRepositoryMappingTests: full entity↔domain round-trip, unknown
  Kind/State/BackupOutcome rejected, legacy BackupOutcome="Failed" rejected.
* CriticalTableBackupServiceTests: happy/partial/empty/budget-exceeded paths
  exercise the per-table loop with in-memory table + blob fakes.

Setup: scripts/Backup/setup-backup-container.ps1 — idempotent az-CLI bootstrap
for container + lifecycle rule (90 d delete on prefix). Soft-Delete and
Versioning are NOT applied automatically (storage-account-wide blast radius
— operator must validate per separate doc note).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-26 15:19:07 +02:00
Oliver Kieselbach b2e17d39f6 security hardnings - nosniff, x-forwaredded-for, ensure-tenantid 2026-05-21 14:26:12 +02:00
Oliver Kieselbach 7a05268250 security hardenings 2026-05-20 19:54:43 +02:00
Oliver Kieselbach c26b25b81b feat(backend,web): offboarding farewell-email capture + dedicated feedback table + reports tabs
- Persist tenant Preview-Notification-Email on OffboardingHistory row at Phase 1 so it
  survives Phase 2 wipe; ResendEmailService.SendAsync wired but disarmed by a const-false
  arm-flag with a [DRAFT] placeholder template — explicit flip required to ship.
- Move user feedback storage out of PreviewConfig discriminator into a dedicated Feedback
  table (PK=InApp / Offboarding) that is NOT in any tenant-offboarding wipe list — feedback
  from departing tenants is exactly the signal we want to keep. New IFeedbackRepository +
  TableFeedbackRepository; FeedbackFunction migrated; (PreviewConfig, "Feedback")
  removed from TenantOffboardingHandler.DiscriminatorTables.
- POST /api/tenants/{tenantId}/offboard/feedback collects a free-form farewell comment
  during the drain-barrier countdown (marker-gated, fail-soft OpsEvent emit). Reports >
  User Feedback page gains an Offboarding-Feedback sub-tab next to In-App Feedback.
- OffboardingFeedbackReceived OpsEventType registered (info-severity) + catalog entry
  in OpsAlertRulesSection so admins can wire Telegram in two clicks.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 12:44:36 +02:00
Oliver Kieselbach bd9fc6f6f4 feat(backend): ScriptNameCache cache negative verdict 2026-05-19 10:18:49 +02:00
Oliver Kieselbach e4c4dd57db feat(backend,web): optional Graph add-on permissions for Intune script display names
Adds opt-in resolution of Intune Platform Script + Remediation display names in
session timelines via a tenant-side appRoleAssignment grant. No app manifest
change, no impact on customers who don't enable it. Architecture is reusable for
future optional Graph permissions: catalog -> detector -> resolver -> endpoint.

Backend
- GraphFeatureDetector parses the `roles` claim from the SP access token and
  caches the (token, roles) bundle per tenant. GetSnapshotAsync surfaces a
  transient flag so admin-UI callers can render "unknown" instead of a
  misleading "0 of 0 granted" when token acquire times out.
- GraphTokenService now accepts a CancellationToken so optional-feature callers
  can apply tight budgets instead of inheriting the 5+15+30 s retry chain.
- ScriptDisplayNameResolver: cache-first, list-pull-on-cold-tenant, per-ID
  fallback. Throttle-aware abort: skip per-ID for kinds whose full-pull hit a
  transient failure; break the per-ID loop on the first HttpRequestException.
  Total budget 5 s, with a tight 1.5 s sub-budget on the token acquire.
- Azure Table `ScriptNameCache` + direct-TableClient fail-loud repository
  (mirrors TableOffboardingAuditRepository pattern). Daily
  ScriptNameCacheCleanupFunction: 7 d positive / 1 h NotFound TTL.
- POST /api/tenants/{id}/scripts/display-names (body-carried refs to dodge URL
  length limits; backend cap 200 refs/request).
- GET /api/tenants/{id}/graph-permissions/status (4 s budget; includes app
  ClientId so the UI can render an exact grant command).
- POST /api/tenants/{id}/graph-permissions/refresh (invalidates token cache).
- All three routes registered in EndpointAccessPolicyCatalog (MemberRead for the
  resolve, TenantAdminOrGA for the admin-UI surfaces).

Web
- `useScriptDisplayNames` hook with stable refset fingerprint (no spurious
  refetches on parent re-renders); clears stale map on tenant/refset change so
  a session switch never leaks names across tenants.
- Chunked POST loop: 200-ref batches with partial-success semantics. Preserves
  earlier chunks when later ones fail (non-2xx OR transport throw); only
  TokenExpiredError propagates so auth flow can react.
- ScriptExecutions renders " . {DisplayName}" alongside existing label/short-id,
  keyed by Kind + policyId so Platform/Remediation under the same id never
  collide.
- New Settings section "Optional Graph capabilities": status table with granted
  / not granted / unknown, copy-paste PS one-liner with ClientId baked in,
  refresh button gated on response.ok.

Customer-facing
- scripts/CustomerSetup/Grant-AutopilotMonitorAddOn.ps1: idempotent
  grant / verify / revoke script. -Features ScriptDisplayNames (high level) or
  -Permissions ... (admin-UI generated). Pure ASCII for PS 5.1 compat.
- docs/customer-graph-add-on-permissions.md: end-to-end customer guide
  covering prerequisites, the appRoleAssignment pattern, and revocation.

Tests
- 1718 backend xUnit / 364 web vitest. Highlights: JWT roles parsing edge
  cases; full-pull-429 suppresses per-ID; per-ID-429 breaks loop; transient
  detection via GetSnapshotAsync; chunked partial success (HTTP failure + thrown
  transport); TokenExpiredError propagation; EndpointPolicyCatalogCompleteness
  covers the three new routes by reflection.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 01:14:21 +02:00
Oliver Kieselbach 206388da99 feat(backend,web): tenant offboarding async cascade with customs archive
Replace synchronous DeleteAllTenantDataAsync with a queued async cascade
that wipes all 24 tenant-scoped tables safely, archives custom rules,
and survives crashes at every step.

Phase 1 (TenantOffboardFunction, ~200ms sync):
  - Write History / ByTenantPointer / Marker rows (OffboardingAudit table)
  - Flip TenantConfiguration.Disabled=true (the active auth gate)
  - Enqueue with 6 min visibility-delay (cache-drain barrier =
    TenantConfigurationService.CacheDuration 5 min + 1 min safety)
  - Return 202 with EarliestProcessingAt for the UI countdown

Phase 2 (TenantOffboardingWorker + Handler, async after drain):
  - Enumerate sessions, per-session cascade-enqueue, persist
    Expectations blob (separate offboarding-state container)
  - Drain probe against per-session progress blobs; self re-enqueue
    up to 60x at 2 min visibility-delay
  - 2.D-archive: GatherRules / AnalyzeRules / ImeLogPatterns moved
    to TenantOffboardingCustomsArchive table (3-iter cap + verify)
  - 2.D-wipe: SafeWipe over all tenant tables (4 variants: exact-PK,
    composite-PK range, discriminator+TenantId, property-only) with
    fetch+verify+delete pattern + SafeWipeVerificationException
  - 2.E: blob-prefix wipe deletion-manifests/{tenantId}/
  - 2.D-pass-2: re-wipe AuditLogs (defense-in-depth)
  - 2.G: side-effects FIRST (Marker, Pointer, blob, audit, OpsEvent)
    then History=Completed LAST (DrainCompletedAt as resume anchor)
  - 2.F-final: delete TenantConfiguration row LAST so self-service
    re-onboarding (next /api/auth/me auto-creates default) works

Defense-in-depth:
  - OffboardingMarkerCleanupFunction (2h timer): sweeps Completed
    markers after 15 min grace, conditional TenantConfiguration sweep
    (only when row is still the offboarding tombstone — preserves
    user-driven self-service re-onboards)
  - Worker poison path: durable Failed state MUST land before queue
    moves; transient failure leaves message visible for retry
  - SessionDeletionHandler reorder: Audit + SignalR notify BEFORE
    CompletedAt write so the drain predicate is correct

Web UI:
  - /settings danger zone: typed "OFFBOARD" confirmation dialog,
    post-202 drain-barrier banner with minute-based countdown, hint
    "you can sign out and close this tab — deletion runs in
    background", silent auto-logout when countdown elapses
  - /admin/customs-archive (GA only): list runs + inspect entries +
    typed "DELETE" confirmation modal for run/entry deletion
  - OpsAlertRulesSection: TenantOffboardingFailed event registered
  - lib/api.ts: shared CustomsArchive* response types

Tests: 100+ new tests covering drain contract, handler ordering,
worker poison path, ETag-CAS, SafeWipe variants, OffboardingFilters
prefix-bleed safety, MarkerCleanup conditional sweep, Bootstrap
auth-disabled-tenant gate.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 23:06:51 +02:00
Oliver Kieselbach 0a8a6eabd7 feat(backend,agent): cert-reject visibility — structured backend warnings + V2 distress cert-context
Backend emits AgentCertRejected (with reason enum) at every cert-validation
failure path and AgentRequestRejected (with request-context) at every 401/403
return so operators can diagnose why mTLS auth was denied. V2 agent now
attaches a Thumbprint/Subject/Issuer/NotBefore/NotAfter snapshot plus a
DistressCertSourceState classifier to each distress report; correlates 1:1
with the backend warnings via tenant + serial + thumbprint.

Distress endpoint stays unauthenticated and security-restrictive: rate-limit
(3-layer) + sanitize + truncate + enum-validation unchanged; new fields are
hex-regex / IsDefined / hard-capped at 96 chars; MaxContentLength bumped from
1024 to 1536 to fit the cert-context payload. V1 agent untouched.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 10:26:52 +02:00
Oliver Kieselbach 1041d44831 feat(backend,web): restore-browser tenant filter — only tenants with snapshot blobs
Adds a "Only tenants with restore data" checkbox to the Restore Browser
tenant dropdown so a Global Admin can hide tenants that have nothing to
recover. Default off (full AdminConfig list); flipping it on fetches a
cheap hierarchy listing once (cached for page lifetime) and intersects
the dropdown with the tenants that own ≥1 snapshot blob.

Backend: new GlobalAdminOnly endpoint
GET /api/global/tenants-with-deletion-manifests backed by
BlobStorageService.ListTenantsWithDeletionManifestsAsync, which uses
GetBlobsByHierarchyAsync with delimiter="/" so Azure returns just the
top-level prefixes (= tenant IDs) without enumerating blobs underneath.
Cost scales with tenant count, not manifest count.

Web: checkbox under the tenant dropdown shows a "(X of Y)" counter when
active, dropdown placeholder reflects the loading state, and the
currently-selected tenant clears if the filter excludes it. Fetch
failures snap the checkbox back to off so the operator never gets
stranded with an empty list.

Build: 0 warnings/errors. Backend 1526/1526 pass. tsc clean.
Vitest 346/346 pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 15:39:57 +02:00
Oliver Kieselbach f1583fc586 fix(backend,web): move cascade-delete admin routes from /api/admin/* to /api/global/*
The /api/admin/* prefix collides with the Azure Functions runtime's built-in
admin paths, which sit behind mTLS client-certificate auth. Browser preflights
to /api/admin/* trigger TLS renegotiation against the Function App's mTLS
boundary and die before our PolicyEnforcementMiddleware ever runs — the
operator sees CORS-shaped errors that aren't actually CORS-shaped.

Repo convention all along has been /api/global/* for cross-tenant GA-only
HTTP endpoints (e.g. global/session-deletions, global/session-reports). The
cascade-delete endpoints landed under /api/admin/* in the original 7-PR
backend branch and went undetected until the admin UI started calling them
from the browser.

Routes renamed:
  admin/sessions/{sessionId}/delete/preview     → global/sessions/{sessionId}/delete/preview
  admin/sessions/{sessionId}/restore            → global/sessions/{sessionId}/restore
  admin/sessions/{sessionId}/deletion-manifest  → global/sessions/{sessionId}/deletion-manifest
  admin/tenants/{tenantId}/deletion-manifests   → global/tenants/{tenantId}/deletion-manifests

Touched:
- 4 Function classes: Route attribute + XML-doc header
- EndpointAccessPolicyCatalog: 4 entries (TenantScoping.RouteParam preserved
  on the tenants route), with a comment explaining why /api/global/* is the
  load-bearing prefix for new GA-only HTTP routes
- DeleteSessionFunction: 3 user-facing "Cascade is poisoned; recover via
  POST /api/.../restore" error messages updated
- SessionDeletion.cs: XML-doc reference to the restore route
- lib/api.ts: 4 frontend URL builders (storedManifest, preview, restore,
  tenantManifests)
- RestoreSessionFunctionTests: 3 EndpointAccessPolicyCatalog.FindPolicy
  lookups
- deleteSessionResponse.test.ts: test fixture message string

No semantic change — only the URL prefix moves. CORS config + JWT middleware
+ policy gating all work the same. The Restore Browser tab + the per-row
Preview / Restore buttons in the Session Cleanup admin page now actually
reach the worker from a browser.

Tests: backend xUnit 1538/1538 pass, web vitest 347/347 pass, dotnet build +
tsc clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 11:38:34 +02:00
Oliver Kieselbach 44076686b6 feat(backend,web): restore-browser tab + detailed dry-run/real-run dialog
Follow-ups on the Session Cleanup admin page after first hands-on review:
an entry point for restoring already-completed cascades (Sessions row gone,
manifest still in the 33-day TTL window), a proper outcome panel that surfaces
what the backend already returned, and a sidebar fix.

== New Restore Browser tab ==
- Backend: GET /api/admin/tenants/{tenantId}/deletion-manifests enumerates
  every persisted snapshot blob under <tenantId>/, parses (sessionId,
  manifestId, sizeBytes, lastModifiedUtc) from the blob name, groups by
  sessionId and sorts both levels by recency. GA-only + TenantScoping.RouteParam
  in EndpointAccessPolicyCatalog (pinned by EndpointPolicyCatalogCompletenessTests).
- BlobStorageService.EnumerateDeletionManifestsByTenantAsync — sibling to
  the existing maintenance-sweep enumerator but tenant-prefixed and yielding
  size + timestamp so the UI can rank without an extra HEAD per blob.
- Web: new tab "Restore Browser" between Stranded and Activity. Tenant
  dropdown (sourced from AdminConfigContext.tenants) → collapsible
  session/manifest tree on the left, manifest summary on the right with a
  Restore… button that opens the dialog with IDs pre-filled. Useful when
  the cascade completed cleanly and the row no longer appears on the
  In-Flight / Poisoned / Stranded tabs.

== Reusable summary view ==
DeletionManifestSummaryView extracted from the modal so the same fetch +
field block + worker-progress + preflight-counts + last-failure panel renders
both inside the modal (Preview button on the other tabs) and inline on the
Restore Browser's right-hand panel. Modal becomes a thin wrapper.

== Dry-run / real-run side-by-side ==
RestoreConfirmDialog now keeps both outcomes visible:
- Two slots (dryRunResult, realRunResult), each rendered as a full result
  section with predicted/actual rows-by-table, inventory re-increments,
  duration, backend message, current state, pending manifest id.
- "Start over" resets both slots so the operator can re-run cleanly.
- Submit buttons gate the workflow: pre-submit shows "Run dry-run" + an
  explicit "Skip dry-run · Restore now"; after dry-run the second button
  becomes "Apply restore" (clear-intent); after real-run all submit
  buttons disappear so accidental double-applies aren't possible.
Previously the dialog discarded everything but Outcome + Mode + dryRun
flag, hiding the actual point of dry-run (the wouldRestoreByTable map).

== Codex review follow-ups (this round) ==
- P2: Restore Browser cleared the displayed sessions only after the new
  fetch returned, so a fast A→B switch (or out-of-order responses) could
  leave the operator looking at tenant A's manifests with the dropdown
  saying B. Fix: clear sessions/expandedSessions/selection synchronously
  on tenant change, abort the in-flight request via AbortController,
  pair the response with a loadedTenantId state, and gate rendering on
  loadedTenantId === selectedTenantId. Three-layer protection so a late
  response that sneaks past the abort can't paint stale data.
- P3: PoisonedTab's onRestored callback closed the dialog immediately
  after a real restore, defeating the new dry-run vs real-run side-by-side
  display. Fix: drop setRestoring(null) from the callback so the dialog
  stays open until the operator dismisses it via Close / Start over.
  Background refresh of the Poisoned list still happens via setRefreshKey.
  Now consistent with the Restore Browser tab.

== Sidebar highlight fix ==
GlobalSidebar marked BOTH ga-ops sub-items active when on the longer
nested route, because each sub used pathname.startsWith(sub.href + "/")
independently. Visible bug: navigating to /admin/ops/session-cleanup lit
up both "Maintenance" (/admin/ops) and "Session Cleanup" in the sidebar.
Fix: compute the longest-matching sub.href once per expanded group, mark
only that one active.

Tests: backend xUnit 1538/1538 pass (one new endpoint registered in the
catalog, picked up by the existing completeness test). Web vitest 347/347
pass. TypeScript strict + dotnet build clean.

Deliberately deferred:
- xUnit for GetTenantDeletionManifestsFunction (same HttpRequestData
  mock-harness gap as the other read endpoints).
- vitest for RestoreBrowserTab + the new dialog flow (defer until a
  real field issue).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 21:48:30 +02:00
Oliver Kieselbach 09bfe7a1bc feat(backend,web): admin session-cleanup UI for V2 cascade-delete + codex follow-ups
Operationalises the 7-PR backend cascade-delete pipeline that landed via commits
c8658b9f..921994e8: per-tenant V2 opt-in switch, audit-trail consolidation, and
a Global-Admin-only Session Cleanup page to track in-flight / poisoned / stranded
cascades and trigger restores.

== PR-A: per-tenant V2 enrollment ==
- UpdateTenantConfigurationFunction extends the GA-only gate list with
  EnableCascadeDeleteV2 so tenant admins cannot self-opt-in / out of the
  snapshot+restore pathway.
- TenantManagementSection.tsx adds a purple-bordered "Enable V2 Cascade Delete"
  checkbox in the GA edit modal and a "Cascade V2" badge on the tenant card so
  rollout state is visible at a glance.

== PR-B: audit consolidation ==
- SessionDeletionHandler drops the 15-per-cascade deletion_step_completed /
  deletion_step_failed / deletion_verification_failed audit emissions. Step
  telemetry lives in DeletionProgress + structured logs; tenant admins now see
  just deletion_started + deletion_completed + deletion_restored.
- SessionDeletionWorker no longer writes a deletion_poisoned tenant audit;
  the signal moved to a new SessionDeletionPoisoned OpsEvent (operator-bound,
  Telegram-routable, dual-registered in OpsAlertRulesSection.tsx catalog).
  IMaintenanceRepository dependency removed from the worker constructor.
- SessionRestoreService stops emitting the deletion_manifest_downloaded
  breadcrumb; deletion_restored is the operator-visible outcome.

== PR-C: Session Cleanup admin page ==
- New /admin/ops/session-cleanup route (gated by app/admin/layout.tsx's
  ProtectedRoute requireGlobalAdmin). Sidebar entry under ga-ops alongside
  Maintenance. Four URL-state tabs: In-Flight / Poisoned / Stranded Queued /
  Activity (links to existing /audit page).
- Backend reads: GetSessionDeletionsListFunction
  (GET /api/global/session-deletions?state=&strandedSinceMinutes=) +
  GetSessionDeletionManifestFunction
  (GET /api/admin/sessions/{id}/deletion-manifest?tenantId=&manifestId=&mode=).
  Both registered as GlobalAdminOnly in EndpointAccessPolicyCatalog.
- DeletionPreviewModal reads the persisted snapshot via the stored-manifest
  endpoint (NOT a fresh dry-run via the existing /delete/preview, which would
  enumerate live-but-partially-deleted data for in-flight cascades). Renders
  manifest counts, snapshot SHA, worker progress (CompletedSteps,
  VerificationDone, TombstoneStarted, aggregate decrements), and the last
  verification failure with residual sample. Download button uses
  authenticatedFetch + Blob + Object URL so the Bearer token is sent.
- RestoreConfirmDialog drops the Full/Partial radio (mode is auto-selected
  by SessionRestoreService based on cascade state — forcing the wrong mode
  would corrupt state). Reason field threads through to deletion_restored
  audit details. Dry-run default = true and previews the auto-selected mode.

== Codex review follow-ups (across four rounds) ==
- High: tenantId is explicitly accepted on /delete/preview (query) and
  /restore (body); GA cross-tenant resolution prefers the explicit value over
  JWT TargetTenantId, falling back to SessionsIndex lookup. Without this, a
  GA acting on a session in another tenant got their JWT home tenant.
- High: new stored-manifest endpoint replaces the dry-run preview for
  in-flight / poisoned rows; previous fix built a fresh manifest against
  already-partially-deleted data and misrepresented the cascade.
- Medium: SessionDeletionPoisoned OpsEvent now carries failureType +
  failureMessage + observedResidualCount + a small residual preview, persisted
  by the handler into DeletionProgress (LastFailureType, LastFailureMessage,
  LastObservedResidualCount, LastResidualSampleJson) before the throw so the
  worker can include them.
- Medium: dry-run restore reports the AUTO-SELECTED mode (full/partial) so
  the dialog's preview reflects what the real run will do, instead of the
  literal string "dryRun".
- Medium: residual count field renamed to observedResidualCount with an
  honest XML doc — CascadeVerificationService caps at 50 per table AND
  short-circuits after the first failing table, so the number is a lower
  bound. UI surfaces "N of 50+ observed rows" when N hits the cap.
- Medium: three-layer cap defense on the OpsEvent residual sample to stay
  under the 4096-char TableOpsEventRepository truncation: max 5 entries
  (OpsEventResidualSamplePreviewSize), per-field max 96 chars with `…` marker
  (OpsEventResidualKeyMaxChars), total preview budget 1200 chars
  (OpsEventResidualPreviewBudgetChars) with trailing-entry drop. Full sample
  stays in the progress blob for the admin UI.
- Low: estimatedSnapshotBytes field name aligned end-to-end; activity tab
  text clarifies audit-action vs OpsEvent distinction.

Tests:
- Backend xUnit 1538/1538 pass (up from 1510 baseline; +28 across the new
  Session Cleanup endpoints, OpsEvent enrichment, restore-mode reporting,
  cap-defense unit + integration tests).
- Web vitest 347/347 pass.
- TypeScript strict + dotnet build clean.

Deliberately deferred:
- xUnit tests for GetSessionDeletionsListFunction +
  GetSessionDeletionManifestFunction (no HttpRequestData mock harness in repo;
  would be a separate refactor).
- vitest for DeletionPreviewModal + RestoreConfirmDialog (defer until first
  field issue).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 20:09:50 +02:00
Oliver Kieselbach c237140d59 fix(backend): cascade-delete codex follow-ups #2 (F1 DELETE cross-tenant, F2 register guard, F3 tombstone marker, F4 cross-tenant test, bootstrap-bypass)
F1 — DELETE /api/sessions/{sessionId} bypassed cross-tenant validation
because the catalog entry had no TenantScoping. A Global Admin clicking
"delete" on a session in tenant B from the portal silently routed the
delete into their own JWT tenant A, never finding the row. Catalog
entry gains TenantScoping.QueryParam; DeleteSessionFunction switches
from TenantHelper.GetTenantId (JWT-only) to RequestContext.TargetTenantId
(middleware-validated).

F2 — RegisterSessionFunction wrote sessions without any deletion-state
check; a mid-cascade register could mutate a Preparing/Queued/Running
row, and a fresh register past the tombstone could re-Add a row the
manifest no longer covers. Guard now runs at the entry of the shared
ProcessRegisterAsync core — covering BOTH /agent/register-session
(cert-auth) AND BootstrapRegisterSessionFunction (bootstrap-code-auth),
which previously bypassed the cert-auth entry's check. 410 Gone on
lock-state or tombstone hit.

F3 — SessionDeletionGuard's "Sessions row missing -> silent pass"
contract let late ingests / registers slip past a completed cascade
and orphan rows the manifest couldn't describe. New short-lived
SessionTombstones table (PK=tenantId, RK=sessionId) carries a marker
written by SessionDeletionHandler.ExecuteTombstoneAsync BEFORE the
Sessions-row delete, with a 7-day default ExpiresAt. Guard now reads
the marker on a missing Sessions row; live and present -> throws with
sentinel state "Tombstoned" -> 410 Gone. Full-Restore deletes the
marker after re-inserting the Sessions row; partial-restore leaves it
alone (never written). SessionDeletionMaintenanceFunction pruning
step runs even when the kill-switch is active (markers are race
shields, not policy artefacts).

F4 — CrossTenantAccessTests had no coverage for DELETE
sessions/{sessionId}. Theory entries lifted into the same three asserts
that already cover GET — same-tenant allowed, cross-tenant non-GA
blocked, cross-tenant GA allowed — and into the QueryParam-scoping
catalog theory so the regression that produced F1 surfaces in CI.

Bootstrap follow-up — RegisterSessionFunction.Run had the guard inline
but BootstrapRegisterSessionFunction calls _inner.ProcessRegisterAsync
directly, bypassing it. Guard moved into ProcessRegisterAsync so both
entry points hit it before StoreSessionAsync. Added structural wiring
tests (DI field, internal accessibility, bootstrap delegation type)
that fail in CI if the architecture regresses to inline copy.

Tests:
- SessionDeletionGuardTests +3: marker-active blocks, no-marker passes
  on missing row, sessions-row-exists skips tombstone probe.
- SessionDeletionHandlerTests +1: marker is written BEFORE the
  Sessions-row delete (Moq Callback ordering assertion).
- SessionRestoreServiceTests +2: full-restore deletes marker,
  partial-restore does not.
- SessionDeletionMaintenanceFunctionTests +2: prunes expired markers,
  pruning runs even with kill-switch active.
- CrossTenantAccessTests +3 inline DELETE cases.
- RegisterSessionGuardWiringTests (NEW): asserts guard DI field,
  ProcessRegisterAsync internal accessibility, BootstrapRegisterSession
  delegation type.
- CascadeVerificationServiceTests.FakeReader gains the new
  GetActiveSessionTombstoneAsync interface member.

Suites: Backend xUnit 1524, Agent V1 530, Agent V2 1042 — all green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 12:08:28 +02:00
Oliver Kieselbach 5dbc44c4ec feat(backend): cascade-delete worker + restore + codex-fixes (PR4/7)
PR4 ships the cascade-delete executor for the safe-cascade-delete plan: the
SessionDeletionWorker (BackgroundService queue poll-loop) plus the
SessionDeletionHandler (per-step execution, live verification, tombstone).
Worker is NOT registered in Program.cs — that lights up in PR5.

Three logical phases land together as one commit per Oliver's call:

PR4 — Worker + Handler + CascadeVerificationService
- SessionDeletionWorker: BatchSize=1, kill-switch entry guard, heartbeat task
  extending visibility every 60s during long handler runs, poison after
  max-dequeue
- SessionDeletionHandler: download manifest+progress, CAS Queued→Running
  (with Running→Running resume), per-step dispatch by Class, AGGREGATE
  decrement, live verification, ordered tombstone (SessionsIndex first,
  Sessions last)
- CascadeVerificationService: per-Class live residual scans (CveIndex is
  manifest-key-only per §12-Q8; AGGREGATE+FINAL skipped)
- NotifySessionDeletedAsync added to ISignalRNotificationService
- BlobStorageService: DownloadDeletionProgressAsync + ETag-CAS
  UpdateDeletionProgressAsync

PR4b — Restore endpoint
- POST /api/admin/sessions/{sessionId}/restore (GA-only via Policy Catalog)
- SessionRestoreService: dispatch table per §13.4, full + partial-poisoned-
  recovery modes
- TableStorageService.Restore.cs: RestoreRowsByExactKeysInBatchesAsync +
  ConvertFromPropValue + ConvertDumpToEntity (Sessions-row override forces
  DeletionState=None on restore so the row isn't permanently locked)
- RestoreSoftwareInventoryEntryByKeyAsync helper
- Worker MoveToPoisonAsync now CAS-transitions Running→Poisoned (E1 fix)
  so restore can dispatch into partial-restore mode

PR4c — Codex review fixes (6 findings)
- F1: AGGREGATE per-key idempotency via DeletionProgress.AggregateDecrementsApplied
  (persist-first/decrement-second bounds drift to +1 per crash vs unbounded -N)
- F2: TombstoneStarted flag closes the gap where sessions=null+completedAt=null
  blocked restore after a tombstone crash
- F3a: Sessions row inserted LAST in full restore (was first → trapped retry
  with RejectAlreadyAtOriginalState)
- F3b: RestoreMode.Full now 409-ignores (was throw-on-409); converges with
  Partial mode, makes retry-after-partial-failure idempotent
- F4: Partial-restore per-key re-increment idempotency via
  DeletionProgress.RestoreReIncrementsApplied
- F5: Poison transition pre-checks PendingDeletionManifestId so stale
  envelopes can't flip a fresh active cascade to Poisoned
- F6: DownloadDeletionManifestWithShaAsync exposes snapshot SHA; Handler +
  Restore both verify against progress.SnapshotSha256 (snapshot↔progress
  binding per plan §3)

DeletionProgress schema gains 3 additive optional fields (AggregateDecrementsApplied,
RestoreReIncrementsApplied, TombstoneStarted) with safe defaults so PR1-PR4
progress blobs deserialize cleanly.

Tests: 1457/1457 backend xUnit facts green (1391 pre-PR4 + 66 new across PR4,
PR4b, PR4c). Full solution build green. Program.cs has zero
SessionDeletionWorker references (PR5 territory stays untouched).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 23:44:20 +02:00
Oliver Kieselbach 257c5c4354 feat(backend,web): cascade-delete state-machine + guard + producer + preview (PR3/7)
PR3 of the safe cascade-delete plan
(.claude/plans/session-deletion-cascade-safe.md). Substantial PR per
plan §16 — adds the concurrency state-machine, the single-chokepoint
SessionDeletionGuard, the producer that builds manifest + uploads
blobs + enqueues the worker envelope, and the read-only preview
endpoint Global Admins use to verify a cascade plan before flipping
EnableCascadeDeleteV2 on. Wiring the guard at the 9 writer sites is
intentionally deferred to PR5 — no cascade actually fires until PR5
calls the producer from DeleteSessionFunction, so PR3 ships as
infrastructure-only.

== State machine + guard infrastructure ==

SessionDeletionState (Shared, Plan §1 P7 / §16-R2):
  None | Preparing | Queued | Running | Poisoned. Completed deliberately
  omitted — the cascade FINAL step removes the row, so "completed" is
  observable via row-absence + progress.completedAt, not via a stable
  state value. IsValidTransition(from, to) enforces the legal edges
  (None→Preparing, Preparing→{Queued,None,Poisoned}, Queued→{Running,
  Poisoned}, Running→Poisoned, Poisoned→None) and treats null/empty
  legacy rows as None.

SessionDeletionLockedException carries (TenantId, SessionId,
CallerContext, CurrentState, ManifestId) for audit + log lines.

SessionDeletionGuard (Plan §1 P7 + §16-R13):
  - ThrowIfLocked(TableEntity sessionRow, callerContext) — piggyback
    variant for hot paths that already loaded the Sessions row; zero
    added I/O. Caller must include DeletionState +
    PendingDeletionManifestId in its select projection.
  - EnsureWritableAsync(tenantId, sessionId, callerContext) —
    standalone variant for paths with no existing read; one Get
    round-trip via the PR1 ISessionDeletionInventoryReader seam.
  Both paths throw the same exception. Per §16-R13 framing the guard
  is "best-effort fast-fail, not safety" — correctness lives in §1 P4
  live verification + poison + §13 restore-from-poisoned (PR4).

== Sessions row state-machine columns ==

SessionSummary.DeletionState (string) +
SessionSummary.PendingDeletionManifestId (string?) — added to the DTO
+ MapToSessionSummary read path. Empty/null means no cascade in flight
(legacy rows; treated as None per the guard contract). The producer
writes both columns via direct CAS via the new helper; existing
session-write paths don't need to know about them in PR3.

TableStorageService.CasSetSessionDeletionStateAsync — new partial
helper. Read-then-CAS on Sessions row, returns a structured
SessionDeletionStateCasResult (Updated | SessionMissing | WrongState |
EtagConflict) so the producer can branch correctly. Stamps newManifestId
on transition to Preparing; clears it on transition back to None
(poisoned-recovery restore).

== Producer + preview endpoint ==

SessionDeletionProducer.EnqueueAsync(tenantId, sessionId, reason,
actor) — Plan §5 PR3 step sequence: kill-switch check → CAS None→
Preparing with new ManifestId → DeletionManifestBuilder.BuildAsync →
BlobStorageService.UploadDeletionManifestAsync → UploadInitial-
DeletionProgressAsync (new helper for the mutable .progress.json
sibling) → audit deletion_started → CAS Preparing→Queued → enqueue
SessionDeletionEnvelope to Constants.QueueNames.SessionDeletion.
Returns SessionDeletionEnqueueOutcome enum so PR5/PR6 callers
translate per their UX (admin → 409 on AlreadyInFlight, maintenance
→ skip+audit). Crash recovery contract: row stuck in Preparing with
no progress blob → maintenance GC clears after 1h (PR6); Queued/
Running/Poisoned NEVER auto-cleared.

GET /api/admin/sessions/{sessionId}/delete/preview?mode={summary|full|
download} — GlobalAdminOnly per the EndpointAccessPolicyCatalog (per
memory feedback_route_policy_catalog). Three modes:
  - summary: preflightCounts + sample (PK, RK) per step + estimated
    snapshot bytes + builderDurationMs + schemaHash.
  - full: entire manifest JSON inline (every row dump, every decrement
    key) — byte-level review of "what would be deleted".
  - download: gzipped *.snapshot.json.gz with Content-Disposition
    attachment, identical bytes to what the real cascade would upload.
NEVER mutates state, NEVER uploads a blob, NEVER enqueues. Calls the
guard only as a hint (in-flight cascades surface in the response, no
lock acquired). Audits deletion_preview per Plan §7.

== Configuration changes (with web round-trip) ==

AdminConfiguration.SessionDeletionKillSwitch (bool, default false,
Plan §1 P8 / §9) — global emergency stop. When true, V2 producer +
legacy direct-delete return 503; PR4 worker pauses on entry. Round-
tripped through TableConfigRepository Store + Map AND the 4-file web
chain (AdminConfigContext.tsx, AdminConfigSettingsSection.tsx,
SectionGlobalSettings.tsx, types/adminConfig.ts) per memory
feedback_admin_config_ui_roundtrip — without it, every admin Save
silently clobbers the field back to false. UI surfaces a red checkbox
with explanatory copy.

TenantConfiguration.EnableCascadeDeleteV2 (bool, default false) —
per-tenant rollout flag. When true, PR5 admin-delete + PR6 retention
fanout build a manifest + enqueue; when false, legacy direct-delete
runs. Per-tenant flag selects PATH; global kill-switch halts BOTH.

Constants.QueueNames.SessionDeletion = "session-deletion" — worker
queue name.

Shared envelope SessionDeletionEnvelope { TenantId, SessionId,
ManifestId, Reason, EnqueuedAt } — wire shape between producer and
PR4 worker.

== DI ==

Program.cs registers SessionDeletionGuard + SessionDeletionProducer
as singletons next to the existing DeletionManifestBuilder.

== Tests (50 new, backend suite 1340/1340 green) ==

- SessionDeletionStateTests (10) — IsLocked over every state value
  including legacy null/empty, IsValidTransition over every legal
  edge, explicit rejection of illegal edges, "Completed is not a
  state" guarantee.
- SessionDeletionGuardTests (8) — null row no-op, every lock state
  throws with correct exception fields, missing-session no-op,
  context propagation in the exception.
- SessionDeletionProducerTests (7) — kill-switch returns
  KillSwitchActive without downstream calls; SessionMissing /
  AlreadyInFlight / Poisoned outcomes from CAS; happy path verifies
  all 7 steps in order via Mock.Verify; CAS-2 failure leaves snapshot
  uploaded but no queue message; producer's pre-allocated ManifestId
  flows to both the snapshot blob and the CAS marker (consistency
  invariant the worker depends on).
- AdminConfigurationSessionDeletionKillSwitchTests (6) — both flags
  default false on new config + on CreateDefault, true persists.

Wiring 9 sites with the guard (telemetry V1+V2 ingest, mark-success/
failed, server actions, maintenance marker, re-analyze handler,
vulnerability handler, manual rescan, index reconcile) deferred to
PR5 — no cascade fires in PR3, so the guard is dormant.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 20:41:32 +02:00
Oliver Kieselbach 7d05a25c90 fix(dashboard/stats): move stats routes out from under sessions/ to dodge Azure Functions route collision
Azure Functions' attribute router does not honour literal-vs-parametric
specificity the way ASP.NET attribute routing does. A literal route of
"sessions/stats" was being eaten by the sibling GetSessionFunction whose
template is "sessions/{sessionId}" — the runtime bound /api/sessions/stats
to GetSession with sessionId="stats", which then 404'd because "stats"
fails GUID validation.

Diagnosed live via App Insights:
  /api/global/sessions/stats -> 200 OK   -> Functions.GetAllSessionStats
  /api/sessions/stats        -> 404      -> Functions.GetSession  (!)

Per-tenant + GA stats routes moved out from under sessions/ for symmetry:
  /api/sessions/stats        -> /api/stats/sessions        (MemberRead)
  /api/global/sessions/stats -> /api/global/stats/sessions (GlobalAdminOnly)

EndpointAccessPolicyCatalog, api.ts, and the URL-builder tests follow.
Function-side + api.ts both carry a comment pinning the rationale so a
future "let's tidy these routes" refactor doesn't regress it.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 15:45:41 +02:00
Oliver Kieselbach b76a6ccaf0 fix(dashboard/stats): server-aggregate cards so they stop drifting with pagination
The five dashboard cards (Active Sessions, Success Rate, Avg. Duration,
Total Today, Failed Today) were derived from whatever sessions the client
had paginated into memory. With default pageSize=10 and SignalR-driven
appends, "Total Today" would jump 40 -> 22 -> 20 within a single visit
as the list grew/shrank. "Last 7 days" was a printed label only - the
client compute had no 7-day filter at all.

Now:
- GET /api/sessions/stats?days=7 (MemberRead, JWT tenant)
- GET /api/global/sessions/stats?days=7&tenantId= (GlobalAdminOnly,
  optional tenant filter; absent = cross-tenant sum)
- TableStorageService.AggregateSessionStats drains SessionsIndex once
  over the window: UTC-midnight boundary for today, success rate over
  terminal sessions only (InProgress no longer drags it down), avg
  duration over Succeeded with DurationSeconds > 0 only.
- useDashboardStats hook owns the cards: initial fetch + debounced
  refetch on SignalR newSession/newevents in the active scope + forced
  refetch on reconnect to recover messages missed during an outage.
- Client compute removed from useDashboardFilters. New
  submittedTenantIdFilter state isolates typing-in-filter from
  submitted-filter so keystrokes never trigger backend round-trips.

Tests: 17 xUnit (aggregation invariants + days parsing) +
7 vitest (URL builder). Suites: backend 1177/1178 (unrelated
RawTablePaginationTests flake), web 266/266.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 13:40:40 +02:00
Oliver Kieselbach 77fde56f30 feat(backend/security): cert-expiry watcher in maintenance with tiered OpsEvents
Adds proactive monitoring of the embedded Intune CA bundle so the operator
gets a Telegram ping months before agent mTLS would break. Bundle rotation
(re-exporting fresh PEMs from a corporate-enrolled device) is the only fix
and needs lead time - today's outage from 9af3de15 was rooted in exactly
this blind spot.

Hooks into the existing 2h MaintenanceService.RunAllAsync timer, iterates
the embedded roots + intermediates, and emits tiered OpsEvents on the
Security category:

  daysUntilExpiry  | Severity | EventType
  -----------------+----------+----------------------------
   <= 7 (or past)  | Critical | EmbeddedCertExpired
   <= 30           | Error    | EmbeddedCertExpiringUrgent
   <= 90           | Warning  | EmbeddedCertExpiringSoon
   else            | -        | (silent)

Plus a Critical EmbeddedCertBundleEmpty event if the loader returns no
roots at all (server misconfiguration / failed deploy).

Dedup is one event per cert per tier per UTC day, by querying today's
Security OpsEvents and matching on EventType + thumbprint extracted from
the Details JSON. Cold starts (Flex Consumption scale-to-zero) rebuild
the dedup index from the table, so repeated process restarts do not
multiply Telegram pings.

Operator action required to receive Telegram alerts: configure four
OpsAlertRules in admin UI - one per new EventType - with appropriate
MinSeverity. The OpsEvents themselves land in the table regardless,
visible in the Ops dashboard.

Tests: 23 new (tier-classifier boundary + dedup-key extraction + JSON
edge cases). Full suite 1098/1098 green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 16:07:06 +02:00
Oliver Kieselbach 0d9ef12f90 chore(backend/security): drop legacy duplicate Intune CA PEMs
intune-root-ca.pem and intune-intermediate-ca.pem are bit-for-bit duplicates
of intune-root-2030.pem and intune-intermediate-mdm-2026.pem (matching SHA1
fingerprints A1:97:D6:... and B7:E4:96:...). The post-9af3de15 loader globs
intune-root-*.pem and intune-intermediate-*.pem and dedupes by thumbprint,
so the duplicates were silently de-duped at load. Remove them now to make
the bundle self-explanatory: one PEM per real CA, name encodes its NotAfter.

Verified by running CertificateValidatorTests (8/8 incl. the real-device-cert
happy-path) which loads the bundle via the same glob path the runtime uses.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 15:59:41 +02:00
Oliver Kieselbach 794800c0cf chore(backend/security): include leaf cert Subject/Issuer/Thumbprint in chain-failure log
Post-deploy of 9af3de15 surfaced 6 chain-validation failures from a single
agent stuck in a 10s retry loop. The current LogWarning only includes the
chain-status string ("unable to get local issuer certificate"), which is
not enough to triage whether the rejected cert is a forge attempt the new
pinning is correctly blocking, or a legit fleet device whose chain we
need to add to the embedded bundle.

Add LeafSubject (GUID-shaped DN for Intune device certs), LeafIssuer (CA
name), and existing Thumbprint to the warning. No PII is leaked - device
certs use opaque GUIDs and the issuer is a public CA DN.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 15:44:15 +02:00
Oliver Kieselbach 9af3de15d4 fix(backend/security): pin client cert chain trust to embedded Intune root (v2)
Re-applies 7e21591b after the chain-validation outage and adds the missing
trust anchors. The first attempt embedded only the 2030 root and the 2026
intermediate; current production device certs chain via the 2026 root +
the 2028 intermediate, so chain.Build() returned "unable to get local
issuer certificate" for every legitimate agent and broke enrolment.

What is different in v2:
- Bundle expanded with both Intune roots (exp 2026-08-12 and 2030-09-15)
  and both currently-issuing MDM Device CA intermediates (exp 2026-05-05
  and 2028-09-15). Real device certs chain via 2028-intermediate -> 2030-
  root today; the 2026 generation is kept for any older device certs
  still in flight.
- Loader now globs intune-root-*.pem and intune-intermediate-*.pem from
  embedded resources and dedupes by thumbprint, so a future Intune root
  rotation is a drop-in PEM with no code change. Legacy generic-named
  files (intune-root-ca.pem, intune-intermediate-ca.pem) are duplicates
  of the new ones and harmlessly de-dup at load.
- Adds a happy-path integration test against a real Intune-issued device
  cert (public-key only, copied from a test VM). This is the regression
  test that would have caught the v1 outage and gates any future bundle
  drift in CI.
- Fixes a pre-existing TZ bug in the expiry check: X509Certificate2's
  NotBefore/NotAfter return Local time, were being compared raw against
  DateTime.UtcNow. Surfaced by the integration test on a CEST dev box;
  unobservable in prod because Linux Flex Consumption runs in UTC.

Trust model is unchanged from v1: X509ChainTrustMode.CustomRootTrust
pinned to the embedded Intune roots, OS trust store ignored, fail-closed
when no roots are loaded. Self-signed-with-Intune-DN bypass remains shut.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 15:28:21 +02:00
Oliver Kieselbach 6658303c0d feat(ops/consent): emit ConsentFlowSuccess to close the consent-flow lifecycle
Pairs with ConsentFlowStarted/Failed in the OpsEvents table so admins can
see whether repeated consent failures (AADSTS, redirect-mismatch, denied)
eventually resolved into a successful grant. New POST endpoint
config/{tenantId}/autopilot-device-validation/consent-success is called
from the post-redirect callback only after consent-status confirms
isConsented=true, so generic status polls do not fire false positives.

Trigger whitelist accepts autopilot, corporate, and device-preparation;
the third value is wired through the type system end-to-end so a future
DevPrep-specific consent flow can use it without a schema bump.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 14:51:54 +02:00
Oliver Kieselbach 5ae7dc40af Revert "fix(backend/security): pin client cert chain trust to embedded Intune root"
This reverts commit 7e21591b55.
2026-05-08 14:35:52 +02:00
Oliver Kieselbach 7e21591b55 fix(backend/security): pin client cert chain trust to embedded Intune root
CertificateValidator previously combined AllowUnknownCertificateAuthority
with a DN-substring scan ("Microsoft Intune"/"MDM Device CA" in
Subject/Issuer of any chain element). A self-signed leaf with that DN
satisfied the substring gate while the unknown-root flag masked the
untrusted-root status -> the X.509 trust gate was bypassable.

Switch to X509ChainTrustMode.CustomRootTrust with the embedded Intune
root in CustomTrustStore and the intermediate in ExtraStore. chain.Build()
now requires a signature link to a pinned root regardless of the OS
trust store, which keeps Linux/Flex Consumption working without the
self-signed bypass. Empty roots -> fail-closed.

Adds 7 unit tests including the self-signed-impersonating-Intune
regression test.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 14:22:20 +02:00
Oliver Kieselbach b6178db8e6 fix(backend/security): enforce body-tenant for non-GA + lift query-tenant guards to middleware
Closes a tenant-boundary write-side hole and removes two manual cross-tenant
checks in favor of catalog-driven middleware enforcement.

SubmitSessionReport + SubmitDiagFilesReport (the violation):
The functions previously fell back to the JWT tenantId only when the body's
TenantId was empty, otherwise trusting the body verbatim. A Tenant Admin of
Tenant-X could POST a body with tenantId=Y and have the report blob, the
SessionReports row, and the audit-log entry all attributed to Tenant-Y
(integrity / non-repudiation issue, no read-side leak).

New behavior: GA-aware. Non-GAs who send a body tenantId that disagrees with
their JWT get a 403 + warning log. GAs may legitimately submit cross-tenant
reports (the UI sends the session's tenantId), but MUST set the body field
explicitly (no silent fallback that would mis-file under the GA's home tenant).

Diagnostics + Progress middleware elevation (defense-in-depth):
diagnostics/download-url and progress/sessions/{sessionId}/events lifted from
TenantScoping.None (in-function guard) to TenantScoping.QueryParam (catalog +
middleware). Same GA bypass semantics — the in-function check was correct but
copy-paste fragile. Functions now read requestCtx.TargetTenantId, which the
middleware has already validated against the JWT.

Test coverage:
CrossTenantAccessTests gains 10 new InlineData rows across the 5 QueryParam
test methods (no-query / same-tenant / cross-non-GA blocked / cross-GA allowed
/ scoping correctness) for both lifted routes. diagnostics/download-url moved
out of JwtImplicitRoutes_NoCrossTenantCheck since it's no longer JWT-implicit.

Verified: dotnet build clean, 1067/1067 backend tests pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 14:12:39 +02:00
Oliver Kieselbach 089c0123e6 feat(reports): add Submit Logs page for session-less diag-files reports
Tenant Admins can now ship log files, JSON state snapshots (.log/.txt/.zip/
.json/.jsonl/.ndjson), and screenshots to the Autopilot Monitor team from
/settings/tenant/support without needing a session context — no more
OneDrive round-trips for ad-hoc client-log requests.

Backend
- New POST /api/diag-files-reports (TenantAdminOrGA) -> SessionReports table
  + session-reports container, distinguished by ReportType="diagFiles".
- ReportType discriminator added to SessionReportMetadata + Store/Map
  (legacy rows default to "session").
- Telegram + Global-Admin bell notifications mirror the session-report flow.

Frontend
- New "Submit Logs" tenant settings tab with comment / email / log+state
  files (5 MB cap) / screenshot pickers; multi-file auto-zip via fflate.
- Admin SessionReportsSection now shows a Type column (Session / Diag Files)
  and hides Session ID in the detail modal for diag-files rows.

Verification: dotnet build clean, 1058 backend xUnit tests pass, tsc
--noEmit clean, 259 vitest tests pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 16:02:55 +02:00