Compare commits

...

14 Commits

Author SHA1 Message Date
Oliver Kieselbach c7ed3ec27d more safe SecureBootCertReader and section plan layout update 2026-07-25 12:48:27 +02:00
Oliver Kieselbach f311e8746b docs(trust): on-demand log collection in customer-facing claims + tracker dedup tests
The "Collect Logs" feature (b382e567) added a second opt-in path into hosted
diagnostics upload — the quick-config dialog on a session — and a new collection
trigger, neither of which the customer-facing pages described. The trust FAQ still
said hosted upload is enabled "in the settings UI", and the dialog itself named
hosted storage without stating that packages leave the tenant boundary, so the
"clearly marked disclosure" promise was not met on that path.

- CollectLogsButton: marked data-boundary disclosure in the quick-config dialog
  (leaves your Azure tenant, per-tenant isolation, blob-scoped write-only token,
  removal per Data Retention Days)
- privacy + about pages: both opt-in paths named; on-demand collection described
  (Admin/Operator of the own tenant, same content as the automatic package,
  delivered with the agent's next check-in, visible on the session timeline)
- docs repo counterpart in trust/security-faq.md (separate commit)

Distress-bell dedup: the TpmPssUnsupported one-shot bell had key-shape tests only.
New HardwareRejectionNotificationTrackerDedupTests pins the 409 dedup branch, the
fail-soft branch, the blank-input short-circuit, the disjoint hardware/tpmpss key
spaces, and the retention sweep — which also prunes tpmpss rows and thereby re-arms
that device's bell, now stated in the interface and implementation docs.

Backend tests 3313/3313, tsc clean, web vitest app/sessions 60/60.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-25 12:43:42 +02:00
Oliver Kieselbach e0b6ce83cf fix null warnings 2026-07-25 00:17:31 +02:00
Oliver Kieselbach 7f7b590db4 fix(web): mobile readability for rule cards + phase timeline overlap
Rule list cards (analyze rules, gather rules, IME log patterns) used a
single-line flex header where every badge was flex-shrink-0 and only the
title could shrink — on phone widths the title truncated to a few
letters. The header now wraps: on mobile the title drops onto its own
full-width row (order-last + w-full) under the badge row and may wrap;
on >=sm the layout is unchanged (title inline, truncated).

PhaseTimeline squeezed up to 8 flex-1 columns with 48px circles and
whitespace-nowrap labels into the viewport, so circles and label text
overlapped completely on mobile. The timeline now has a minimum width
of 80px per phase inside an overflow-x-auto container (swipe on phone,
no scrollbar on desktop) and labels wrap inside their own column
instead of overlaying neighbours.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-25 00:04:04 +02:00
Oliver Kieselbach 4c2c447a2e fix(web): dark-mode slate/hover-tint overrides + mobile wrap for script executions
Dark mode: globals.css was missing the entire slate family (the
"Incomplete" status filter chip rendered white-on-white) and all light
hover tints outside gray/indigo/cyan (hover:bg-purple-50 in the tenant
suggestion dropdown flashed near-white under light text). Added slate
bg/text/border mappings mirroring gray, plus dark equivalents for every
hover:bg-*-50/100/200 class in use (blue, purple, red, amber, yellow,
green, orange, sky, teal, emerald, slate, gray-200/300).

Mobile: ScriptExecutions rows were single-line flex with flex-shrink-0
right clusters, so badges and status text overlapped/overflowed on
narrow screens. Header counters, cycle card headers and item rows now
flex-wrap with gap spacing instead of space-x.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-24 23:46:03 +02:00
Oliver Kieselbach 8a794a1b52 fix(rules): cross-tenant GA rule edits no longer misroute to home tenant
A Global Admin editing a gather/analyze rule while viewing a FOREIGN
tenant (override) got a green "saved" banner, but the write landed in
the GA's OWN home tenant: the JWT-scoped rules/{type}/{ruleId} routes
resolve the tenant from the caller's token, and no cross-tenant write
route existed (reads had /api/global/rules/*, writes did not). The
reload re-read the foreign tenant and showed the unchanged rule.

Fix: symmetric cross-tenant WRITE routes mirroring the global reads.

Backend:
- GlobalRulesFunction: PUT/DELETE global/rules/gather/{ruleId} and
  global/rules/analyze/{ruleId}; tenant from ?tenantId= (400 if
  missing), same 1 MB guard + ValidateScopeAndEmitMode as the JWT
  variants, same service calls (toggle-partial/built-in-state/full-edit
  semantics are tenant-parameterized and identical).
- Catalog: 4 entries GlobalAdminOnly + TenantScoping.QueryParam — a
  write tier, so the delegated-read rescue never fires; only a platform
  admin may mutate another tenant's rules. No global CREATE route:
  create stays JWT-only and the portal hides Create in override.

Web:
- api.rules.globalGatherRule/globalAnalyzeRule helpers; both rule pages
  route save/toggle/mark-failed/notify/delete through a
  *RuleWriteUrl(ruleId) switch on isGlobalOverride.
- Analyze template-enable (a create) is blocked in override with a
  clear message instead of silently writing to the wrong tenant.

Tests: CrossTenantAccessTests guards the 4 routes are GA-only +
QueryParam and that a GA reaches the named target tenant. Backend
3295/3295, web vitest 627/627, tsc clean.

Note: the old bug planted orphan copies of foreign rules into the GA's
home tenant on each attempted cross-tenant edit; those are not
auto-cleaned.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-24 23:21:37 +02:00
Oliver Kieselbach 6c53dc33e7 feat(distress): tenant-visible TpmPssUnsupported devices + one-shot bell
TpmPssUnsupported distress reports (TPM cannot RSA-PSS-sign, Schannel drops
the client cert on Win11 25H2+, agent can never authenticate) were operator-
only, yet the remediation is tenant-side: TPM firmware update or device
replacement.

- New GET audit/tpm-pss-unsupported (MemberRead), aggregated per serial,
  mirroring audit/device-not-registered
- Bell notification "tpm_pss_unsupported" (Admin audience) once per
  (tenant, serial); dedup via the existing rejection tracker table with a
  disjoint "tpmpss|{serial}" row-key space; no webhook by design
- Web: TpmIncompatibleDevicesInsights panel in the Hardware Whitelist
  section; renders only when a device is affected (no permanent card)

No agent change, no ConfigVersion bump. Backend tests 3287/3287, tsc clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-24 20:47:06 +02:00
Oliver Kieselbach 801891d57a layout update section plan comparison 2026-07-24 20:30:28 +02:00
Oliver Kieselbach 1f6efca95b fix(backend): stamp DiagnosticsBlobName from on-demand server_action_executed events
Session 8e4cc4ae: two "Collect Logs" round trips completed on the agent (both
ZIPs landed in hosted storage) but the portal never flipped to Download and
the button hung at "Collecting..." — the ServerActionDispatcher confirms an
on-demand upload only via server_action_executed(actionType=request_diagnostics,
blobName=...) and emits NO diagnostics_uploaded (only the terminal upload path
does), so UpdateSessionDiagnosticsBlobAsync never ran.

The ingest classification now treats such an executed event as the upload
confirmation (a real diagnostics_uploaded in the same batch still wins — it
carries destination). Because the executed event has no destination field,
InferDiagnosticsDestination derives "Hosted" from the backend-built
"{tenantId}/{filename}" blob-name shape (CustomerSas blobs are bare
filenames); without it the download route would look in the customer's
container for a hosted blob.

Backend-only — works with the already-deployed agent fleet, no agent release.
Backend suite 3274/3274.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-24 19:23:47 +02:00
Oliver Kieselbach 9ec8644be2 feat(settings): operator read-only settings view with real values
Operators previously saw editable settings forms bound to hardcoded defaults
whose Save always 403'd. Now they see the tenant's real configuration
read-only, with no save affordances at all.

Backend:
- GET config/{tenantId}: TenantAdminOrGlobalReader -> MemberRead. Safe because
  the handler redacts by default (CanViewSecrets: only GA or own-tenant Admin
  see secrets); Operators/Viewers receive RedactedCopyForReader().
- POST diagnostics/files: TenantAdminOrGA -> TenantAdminOrOperator (submitting
  logs is a troubleshooting action; Viewer and read-only GlobalReader excluded).
- Tests: policy pins updated; new middleware tests (own-tenant Operator allowed
  + redacted, cross-tenant Operator 403); CanViewSecrets theory for
  Operator/Viewer.

Web:
- TenantConfigContext loads the full config for all settings users (403
  fallback to feature-flags minimal config for deploy-order safety) and
  exposes canEditConfig; saveConfiguration guards on it.
- New ReadOnlyFieldset wraps section content (native fieldset disable, values
  stay copyable); SaveResetBar is not rendered at all for read-only viewers.
  Applied to Contact, SLA Targets, Hardware Whitelist (+ rejection-insights
  toggle), Notifications, Agent Settings, Agent Analyzers, Diagnostics, Data
  Management.
- Hidden entirely from Operators (in-page admin-only card + sidebar sub-item
  gate): Autopilot Validation, Access Management, Offboarding, Unrestricted
  Mode. GlobalReader sidebar parity kept via hasGlobalScope.

Tests: backend 3257/3257, web vitest 627/627, tsc clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-24 18:40:27 +02:00
Oliver Kieselbach 9acd10e1dc docs(okf): ServerActions & on-demand diagnostics concept doc
Covers the queue-on-session/deliver-on-ingest channel, the
TenantAdminOrOperator tier with in-function per-type re-gating, the portal
Collect Logs flow (quick-config orders rotate_config before
request_diagnostics), sequence-based progress matching, and the hosted-prefix
sweep in the offboarding cascade. Registered in index.md, logged in log.md.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-24 18:15:23 +02:00
Oliver Kieselbach b382e5678e feat(web): "Collect Logs" on-demand diagnostics collection on the session detail page
Queues a request_diagnostics ServerAction (delivered with the agent's next
ingest response) and tracks progress live: server_action_received flips the
button to "Collecting…", the DiagnosticsBlobName SignalR delta completes it
(Download Diagnostics appears), server_action_failed or a 3-min quiet timeout
surface as toasts.

- Button is ALWAYS rendered — disabled states carry tooltips (inactive
  session, cross-tenant view, missing role, diagnostics not configured) as a
  deliberate nudge towards enabling diagnostics upload.
- Tenant Admin on an unconfigured tenant gets a quick-config dialog: switches
  the tenant to Hosted + OnFailure (GET config -> patch two fields -> PUT
  verbatim), then queues rotate_config BEFORE request_diagnostics so the agent
  collects against the fresh config. Operators/Viewers never see the dialog.
- Operator may collect when configured (new TenantAdminOrOperator backend tier).
- useSessionTenantConfig picks up diagnosticsUploadConfigured from the
  member-readable feature-flags endpoint.
- Progress matching is sequence-based (backend-assigned) so agent clock skew
  and at-least-once action redelivery cannot produce false matches.

Pure state logic in collectLogsLogic.ts with vitest matrix (21 tests).
tsc clean; web suite 627/627.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-24 18:12:49 +02:00
Oliver Kieselbach 2fee50276e feat(backend): operator-tier session actions + diagnostics groundwork for on-demand log collection
- New EndpointPolicy.TenantAdminOrOperator (Admin/Operator/GA, no Viewer) with
  middleware evaluator; POST sessions/{sessionId}/actions moves to this tier so
  troubleshooting operators can queue request_diagnostics ("Collect Logs").
- QueueSessionActionFunction re-gates per type: terminate_session and
  rotate_config stay Admin/GA-only (403 for operators).
- GET config/{tenantId}/feature-flags exposes diagnosticsUploadConfigured so
  members below Admin can see whether an on-demand upload would work.
- Offboarding cascade: hosted diagnostics deletion now sweeps the session
  prefix {tenantId}/AgentDiagnostics-{sessionId}- (plus the manifest-named
  blob) so on-demand packages overwritten on the Sessions row by the terminal
  package no longer survive deletion. CustomerSas branch unchanged.
- Notification dismiss routes deliberately NOT migrated: dismissal is still
  tenant-shared; operators would clear admin-tier notifications they cannot see.

Tests: middleware role matrix on the actions route, per-type gate matrix,
feature-flag matrix, prefix-sweep + real hosted-branch cascade tests.
Backend suite 3253/3253.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-24 18:05:39 +02:00
Oliver Kieselbach 8bb02acc4a fix(web): bump postcss to >=8.5.12 (GHSA-6g55-p6wh-862q, high)
Dependabot alert #74: PostCSS <= 8.5.11 allows arbitrary file read /
information disclosure via an attacker-controlled sourceMappingURL in CSS
comments. Raise the floor (devDependency + override) from ^8.5.10 to
^8.5.12; the lockfile now resolves postcss 8.5.22 (its nanoid dep moves
3.3.11 -> 3.3.16 with it). npm audit reports 0 vulnerabilities.

Next.js pins postcss 8.4.31 transitively, but the existing root override
collapses the tree to a single postcss node, so the bump covers it too.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-24 16:54:57 +02:00
77 changed files with 3248 additions and 220 deletions
+15
View File
@@ -37,6 +37,21 @@ Fail-soft contract like `OobeStateReader`: never throws; failures map to status
(`not_uefi` = legacy BIOS via `ERROR_INVALID_FUNCTION`, `variable_not_found`,
`privilege_denied`, `error_<code>`).
This is the repo's only privilege-adjustment code, so it also carries the ownership rules
any future one should copy:
* The process token lives in a `SafeHandle` assigned by the P/Invoke marshaler, so the CLR
owns it from the moment `OpenProcessToken` returns — the critical finalizer releases it
even if `Dispose` never runs (async exception before the instance reaches its `using`).
* The restore obligation is recorded immediately after the successful `AdjustTokenPrivileges`,
and only when the privilege was not *already* enabled; nothing that can throw sits between
the adjustment and that assignment. `Dispose` is idempotent, swallows a failed restore, and
releases the handle in a `finally`.
* The privilege is adjusted on the **process** token, so enable → read → restore is a
process-wide side effect and runs under a static gate. Without it a concurrent reader would
see "already enabled", correctly skip its own restore, and then lose the privilege mid-read
when the first reader disposes — a spurious `privilege_denied` on a machine that holds it.
Fields land on the existing `secureboot_status` event (once per agent start,
restart-deduped by the StartupEventGate): `uefiFirmwareReadStatus`, per-cert booleans
(`uefiDbHasWindowsUefiCa2023`, `uefiDbHasWindowsProductionPca2011`,
@@ -0,0 +1,102 @@
---
type: Concept
title: ServerActions & On-Demand Diagnostics Collection
description: The queue-on-session, deliver-on-ingest ServerAction channel (terminate_session / rotate_config / request_diagnostics), the operator-tier authorization split, the portal's Collect Logs flow including the admin quick-config path, and why the offboarding cascade sweeps the hosted diagnostics prefix.
resource: /src/Backend/AutopilotMonitor.Functions/Functions/Sessions/QueueSessionActionFunction.cs
tags:
- backend
- agent
- web
- server-actions
- diagnostics
- authorization
timestamp: 2026-07-24T00:00:00+02:00
---
# Schema
**Channel.** A `ServerAction` (`Type`, `Reason`, `RuleId`, `Params`, `QueuedAt`) is queued onto
`Sessions.PendingActionsJson` by server-side writers — the admin endpoint
`POST /api/sessions/{sessionId}/actions`, the RuleEngine, maintenance — and delivered
piggy-backed on the agent's next ingest response. No polling channel exists; delivery latency
equals the agent's telemetry cadence (seconds to ~1 minute during enrollment). Delivery is
at-least-once: the agent's `ServerActionDispatcher` dedups on `Type|RuleId|QueuedAt` and
processes a batch sequentially in queue order. Unknown types are logged and skipped, so new
types roll out server-first. Every action emits `server_action_received` and then
`server_action_executed` or `server_action_failed` into the session timeline.
**Authorization (two layers).** The route sits on `EndpointPolicy.TenantAdminOrOperator`
(tenant Admin, tenant Operator, or Global Admin — no Viewer, no Global Reader: it is a write
tier). Because the route serves multiple action kinds, `QueueSessionActionFunction`
re-gates per type via `IsTypeAllowedForCaller`: Operators may queue only
`request_diagnostics`; `terminate_session` and `rotate_config` require
`RequestContext.IsTenantAdmin || IsGlobalAdmin` (403 otherwise). Any route on this tier with
a multi-kind body MUST follow this same in-function re-gating pattern.
**On-demand collection (`request_diagnostics`).** The dispatcher calls
`DiagnosticsPackageService.CreateAndUploadAsync(enrollmentSucceeded: false, suffix:
"server-requested")` — producing `AgentDiagnostics-{sessionId}-{timestamp}-server-requested.zip`.
Passing `enrollmentSucceeded: false` is what makes on-demand collection work under
`DiagnosticsUploadMode=OnFailure`; only `Off` (or an unconfigured destination) blocks it,
surfacing as `server_action_failed`. The `diagnostics_uploaded` event stamps
`DiagnosticsBlobName` + `Destination` onto the Sessions row (latest wins — an on-demand name
is later replaced by the terminal package), and the SignalR `sessionUpdate` delta carries the
field, so the portal's Download button flips live.
**Portal flow (Collect Logs, session detail header).** The button is always rendered; the
disabled states carry tooltips (inactive session, cross-tenant view, missing role, diagnostics
not configured) as a deliberate nudge. Members below Admin cannot read the tenant config, so
the member-readable feature-flags endpoint exposes `diagnosticsUploadConfigured`
(`mode != Off && (customer SAS present || destination Hosted)`). Progress tracking is
sequence-based (backend-assigned, monotonic), never timestamp-based — agent clock skew and
at-least-once redelivery of older actions cannot produce false matches
(`collectLogsLogic.ts`). Cross-tenant views are gated off because `TenantHelper.GetTenantId`
resolves the tenant strictly from the caller's JWT.
**Admin quick-config.** For an unconfigured tenant, a Tenant Admin's click opens a dialog
that switches the tenant to `Destination=Hosted`, `Mode=OnFailure` (GET full config → patch
exactly those two fields → PUT verbatim), then queues `rotate_config` **before**
`request_diagnostics` — the dispatcher's sequential ordering guarantees the agent refetches
the now-enabled config (the `RemoteConfigMerger` applies diagnostics fields live, remote wins)
before building the package. This works against the already-deployed agent fleet; a
"one-time override" variant would not, because the `mode=Off` check is client-side in
`CreateAndUploadAsync` and the deployed handler reads no params.
**Offboarding cascade.** Latest-wins on `DiagnosticsBlobName` means a session can leave more
hosted blobs behind than the deletion manifest references. The cascade's Hosted branch
therefore sweeps the canonical prefix `{tenantId}/AgentDiagnostics-{sessionId}-`
(`HostedDiagnosticsBlobService.DeleteBySessionPrefixAsync`) plus the manifest-named blob as
belt-and-braces; a non-GUID legacy `SessionId` degrades to the single-name delete instead of
poisoning the cascade. The CustomerSas branch is unchanged (delete only with the `d`
permission; the customer's lifecycle rules stay authoritative).
# Examples
Queue an on-demand collection (Admin or Operator, own tenant):
```
POST /api/sessions/{sessionId}/actions
{ "type": "request_diagnostics", "reason": "On-demand log collection from portal" }
→ 202 { "success": true, "queuedAt": "..." }
```
Operator queueing an admin-only type:
```
POST /api/sessions/{sessionId}/actions
{ "type": "terminate_session" }
→ 403 { "message": "Action type 'terminate_session' requires the Tenant Admin role" }
```
Timeline for a successful round trip: `server_action_received` → `server_action_executed`
(with `blobName`) → `diagnostics_uploaded` → Sessions row + SignalR delta carry the new
`DiagnosticsBlobName`.
# Citations
* `src/Backend/AutopilotMonitor.Functions/Functions/Sessions/QueueSessionActionFunction.cs` — allowed types, per-type caller gate.
* `src/Backend/AutopilotMonitor.Functions/Security/EndpointAccessPolicyCatalog.cs` — `TenantAdminOrOperator` tier definition.
* `src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Runtime/ServerActionDispatcher.cs` — dedup, sequential dispatch, telemetry contract.
* `src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Runtime/DiagnosticsPackageService.cs` — mode gates, archive build, suffix.
* `src/Backend/AutopilotMonitor.Functions/Services/Diagnostics/DiagnosticsBlobCascadeDeleter.cs` — hosted prefix sweep.
* `src/Web/autopilot-monitor-web/app/sessions/[sessionId]/components/collectLogsLogic.ts` — button gating matrix, sequence-based progress evaluation.
+1
View File
@@ -32,6 +32,7 @@ https://docs.autopilotmonitor.com (separate repository).
# Backend
* [Business Timestamps](backend/business-timestamps.md) - Why the Azure Tables system Timestamp is never authoritative (migrations reset it), and the OccurredUtc + RowKey-decode compensation for AuditLogs/OpsEvents/Events after the 2026-07-18 migration.
* [ServerActions & On-Demand Diagnostics](backend/server-actions-on-demand-diagnostics.md) - The queue-on-session, deliver-on-ingest action channel, the TenantAdminOrOperator tier with in-function per-type re-gating, the portal's Collect Logs flow (quick-config orders rotate_config before request_diagnostics), and the hosted-prefix sweep in the offboarding cascade.
# MCP Server
+6
View File
@@ -1,7 +1,13 @@
# Log
## 2026-07-25
* **Update**: `agent/secureboot-ca2023-detection.md` — review follow-up on 90012d9 hardened the privilege scope, the repo's only privilege-adjustment code and therefore the precedent for any future one. The process token moved from a raw `IntPtr` to a `SafeHandle` assigned by the P/Invoke marshaler, so the CLR owns it from the moment `OpenProcessToken` returns and the critical finalizer releases it even when `Dispose` never runs — previously an async exception between the successful open and the caller's `using` leaked the handle *with the privilege enabled and no owner left to restore it*. `Dispose` became idempotent and now releases the handle in a `finally`, so a throwing restore cannot strand it; the restore obligation is recorded immediately after the successful `AdjustTokenPrivileges` with nothing throwable in between. The whole enable → read → restore sequence runs under a static gate because the privilege is adjusted on the *process* token: a concurrent reader would see "already enabled", correctly skip its own restore, and then lose the privilege mid-read when the first reader disposed, reporting `privilege_denied` on a machine that holds it.
## 2026-07-24
* **Creation**: Added `backend/server-actions-on-demand-diagnostics.md` — the ServerAction channel (queue on `Sessions.PendingActionsJson`, deliver piggy-backed on the next ingest response, at-least-once with `Type|RuleId|QueuedAt` dedup and sequential dispatch) and the on-demand log collection built on it: the portal's always-visible **Collect Logs** button, the new `EndpointPolicy.TenantAdminOrOperator` tier with the mandatory in-function per-type re-gate (Operators queue only `request_diagnostics`), the member-readable `diagnosticsUploadConfigured` feature flag, the admin quick-config path that must queue `rotate_config` before `request_diagnostics` (works against the deployed fleet — a one-time override would not, the mode gate is agent-side), sequence-based progress matching immune to agent clock skew, and the offboarding cascade's hosted-prefix sweep closing the latest-wins orphan-blob gap.
* **Update**: `agent/esp-policy-provider-stall.md` — the customer's follow-up on issue #106 corrected the field-case mechanics: `TrackingPoliciesCreated=1` WAS set under the foreign `ConfigMgr` provider, so the ESP ignores a foreign provider's completion values and waits on the Intune-registered `Sidecar` provider by name (the rename to `Sidecar` creates no tracking entries yet unblocks instantly). The completeness-keyed detector would have missed exactly this — the incident it was built for. The tripwire is now two-armed under one event type discriminated by `reason`: `provider_incomplete` (unchanged dwell on continuously-incomplete providers) and `sidecar_provider_missing` (≥ 1 Setup/Apps provider registered without `Sidecar`, completeness deliberately ignored, clock reset when Sidecar registers later). Sidecar-missing wins the `reason` when both arms cross in one round; the cross-restart fingerprint gains a `sidecarMissing` sentinel; payload adds `reason` and `sidecarMissingForMinutes`. Evidence for the name-keyed wait: Niehaus' documented Intune WAP payload always registers `Sidecar` (ConfigMgr only via a co-management settings policy) and Microsoft's ESP troubleshooting models app tracking exclusively through the Sidecar subkey.
* **Creation**: Added `agent/esp-policy-provider-stall.md` — a customer diagnosed that pre-provisioned devices with the ConfigMgr client as a blocking app occasionally park the user ESP at "Apps (Identifying)" for days: only a `ConfigMgr` provider is registered under `EnrollmentStatusTracking\Device\Setup\Apps\PolicyProviders` (no `Sidecar` = IME) and `TrackingPoliciesCreated` never becomes 1 — a wait the CSP defines without any timeout; renaming the key to `Sidecar` unblocks immediately. The agent now ships the always-on `esp_policy_provider_stalled` tripwire (60-s tick, 15-min continuous-incompleteness dwell, one-shot per provider, `StartupEventGate` fingerprint across restarts, purely observational via the InformationalEvent rail — no toggle, no ConfigVersion bump), generalized to any provider in both `Setup/Apps` and `DevicePreparation` kinds rather than hardcoding the ConfigMgr case. Companion change: `SOFTWARE\Microsoft\Windows\Autopilot` joins the gather-rule registry allowlist so tenants can target the subtree with custom rules (e.g. `not_exists` on the Sidecar key).
@@ -161,6 +161,40 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring
Assert.False(string.IsNullOrEmpty(snapshot.KekStatus));
}
/// <summary>
/// SeSystemEnvironmentPrivilege is adjusted on the PROCESS token, so the enable →
/// read → restore sequence is a process-wide side effect. Unserialized, a second
/// reader observes "already enabled", skips its own restore, and then loses the
/// privilege mid-read when the first reader disposes — surfacing as a privilege_denied
/// that disagrees with the other readers. The machine's actual privilege state does
/// not matter here: whatever it is, every reader must report the SAME status.
/// </summary>
[Fact]
public void Read_is_serialized_across_concurrent_callers()
{
const int readers = 8;
var snapshots = new UefiSecureBootCertSnapshot[readers];
var failures = new System.Exception[readers];
var threads = new System.Threading.Thread[readers];
for (var i = 0; i < readers; i++)
{
var index = i;
threads[i] = new System.Threading.Thread(() =>
{
try { snapshots[index] = UefiSecureBootCertReader.Read(); }
catch (System.Exception ex) { failures[index] = ex; }
});
threads[i].Start();
}
foreach (var thread in threads) thread.Join();
Assert.All(failures, Assert.Null);
Assert.All(snapshots, s => Assert.False(string.IsNullOrEmpty(s.DbStatus)));
Assert.All(snapshots, s => Assert.Equal(snapshots[0].DbStatus, s.DbStatus));
Assert.All(snapshots, s => Assert.Equal(snapshots[0].KekStatus, s.KekStatus));
}
private static byte[] Concat(params byte[][] parts)
{
var total = 0;
@@ -1,5 +1,6 @@
using System;
using System.Runtime.InteropServices;
using Microsoft.Win32.SafeHandles;
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Interop
{
@@ -56,6 +57,13 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Interop
private const int InitialBufferSize = 64 * 1024;
private const int RetryBufferSize = 256 * 1024;
// SeSystemEnvironmentPrivilege is adjusted on the PROCESS token, so enable → read →
// restore is a process-wide side effect and must run as one atomic sequence. Without
// this gate a second reader would observe "already enabled", correctly skip its own
// restore, and then have the privilege disabled underneath it when the first reader
// finishes — a spurious privilege_denied on a machine that actually holds it.
private static readonly object FirmwareReadGate = new object();
/// <summary>
/// Reads db and KEK and evaluates the certificate presence flags. Never throws;
/// per-variable failures surface as <see cref="UefiSecureBootCertSnapshot.DbStatus"/> /
@@ -67,6 +75,10 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Interop
var snapshot = new UefiSecureBootCertSnapshot();
try
{
// The critical section covers the whole privileged window, not just the
// adjustment — see FirmwareReadGate. Only synchronous kernel calls and byte
// scans run inside it, so it cannot block on anything else the agent does.
lock (FirmwareReadGate)
using (var privilege = SystemEnvironmentPrivilege.TryEnable())
{
if (!privilege.Enabled)
@@ -168,23 +180,42 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Interop
/// of the instance and restores the previous privilege state on dispose. SYSTEM holds
/// the privilege (default-disabled); without elevation <see cref="Enabled"/> stays
/// false and the caller reports <see cref="StatusPrivilegeDenied"/>.
/// <para>
/// <b>Crash-safety.</b> This is the repo's only privilege-adjustment code, so the
/// ownership rules are spelled out: the token is held in a <see cref="SafeHandle"/>
/// assigned by the P/Invoke marshaler, so the CLR owns it from the moment
/// <c>OpenProcessToken</c> returns — it is released by the critical finalizer even if
/// this instance never reaches the caller's <c>using</c> (async exception between the
/// call and the assignment). <see cref="Dispose"/> is idempotent, never throws, and
/// releases the handle in a <c>finally</c> so a failed restore cannot leak it. The
/// restore obligation is recorded immediately after the successful adjustment, so no
/// throw in between can strand the process with the privilege left enabled.
/// </para>
/// </summary>
private sealed class SystemEnvironmentPrivilege : IDisposable
{
public bool Enabled { get; private set; }
private IntPtr _token = IntPtr.Zero;
private SafeTokenHandle _token;
private NativeMethods.TOKEN_PRIVILEGES _previousState;
private bool _restoreNeeded;
private bool _disposed;
public static SystemEnvironmentPrivilege TryEnable()
{
var instance = new SystemEnvironmentPrivilege();
try
{
SafeTokenHandle token;
if (!NativeMethods.OpenProcessToken(NativeMethods.GetCurrentProcess(),
NativeMethods.TOKEN_ADJUST_PRIVILEGES | NativeMethods.TOKEN_QUERY, out instance._token))
NativeMethods.TOKEN_ADJUST_PRIVILEGES | NativeMethods.TOKEN_QUERY, out token))
{
// Failed open still yields a (closed/invalid) SafeHandle instance.
if (token != null) token.Dispose();
return instance;
}
instance._token = token;
NativeMethods.LUID luid;
if (!NativeMethods.LookupPrivilegeValueW(null, "SeSystemEnvironmentPrivilege", out luid))
@@ -197,52 +228,80 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Interop
Attributes = NativeMethods.SE_PRIVILEGE_ENABLED
};
NativeMethods.TOKEN_PRIVILEGES previousState;
uint returnLength;
if (!NativeMethods.AdjustTokenPrivileges(instance._token, false, ref newState,
if (!NativeMethods.AdjustTokenPrivileges(token, false, ref newState,
(uint)Marshal.SizeOf(typeof(NativeMethods.TOKEN_PRIVILEGES)),
out instance._previousState, out returnLength))
out previousState, out returnLength))
return instance;
// AdjustTokenPrivileges returns TRUE even when the privilege is not held —
// the real verdict is ERROR_NOT_ALL_ASSIGNED in the last error.
// the real verdict is ERROR_NOT_ALL_ASSIGNED in the last error. Nothing was
// adjusted in that case, so there is nothing to restore.
if (Marshal.GetLastWin32Error() == ErrorNotAllAssigned)
return instance;
// Restore only when the privilege was not already enabled before.
instance._restoreNeeded = instance._previousState.PrivilegeCount > 0
&& (instance._previousState.Attributes & NativeMethods.SE_PRIVILEGE_ENABLED) == 0;
// The token is now modified: record the restore obligation before anything
// else can throw. Restore only when the privilege was not already enabled.
instance._previousState = previousState;
instance._restoreNeeded = previousState.PrivilegeCount > 0
&& (previousState.Attributes & NativeMethods.SE_PRIVILEGE_ENABLED) == 0;
instance.Enabled = true;
}
catch
{
// Fail-soft: Enabled stays false.
// Fail-soft: Enabled stays false. Whatever was acquired before the failure
// is already tracked on the instance and released by Dispose.
}
return instance;
}
public void Dispose()
{
if (_token != IntPtr.Zero)
if (_disposed) return;
_disposed = true;
Enabled = false;
var token = _token;
_token = null;
if (token == null) return;
try
{
try
if (_restoreNeeded)
{
if (_restoreNeeded)
{
NativeMethods.TOKEN_PRIVILEGES ignored;
uint returnLength;
NativeMethods.AdjustTokenPrivileges(_token, false, ref _previousState,
(uint)Marshal.SizeOf(typeof(NativeMethods.TOKEN_PRIVILEGES)),
out ignored, out returnLength);
}
_restoreNeeded = false;
NativeMethods.TOKEN_PRIVILEGES ignored;
uint returnLength;
NativeMethods.AdjustTokenPrivileges(token, false, ref _previousState,
(uint)Marshal.SizeOf(typeof(NativeMethods.TOKEN_PRIVILEGES)),
out ignored, out returnLength);
}
catch
{
// Best effort — the process token privilege state is not load-bearing
// for anything else the agent does.
}
NativeMethods.CloseHandle(_token);
_token = IntPtr.Zero;
}
catch
{
// Best effort — the process token privilege state is not load-bearing for
// anything else the agent does, and Dispose must never throw.
}
finally
{
token.Dispose();
}
}
}
/// <summary>
/// Owns a process-token handle. <c>ReleaseHandle</c> runs on the finalizer thread when
/// <see cref="SystemEnvironmentPrivilege.Dispose"/> is skipped, so it must not throw —
/// a single <c>CloseHandle</c> P/Invoke satisfies that.
/// </summary>
private sealed class SafeTokenHandle : SafeHandleZeroOrMinusOneIsInvalid
{
public SafeTokenHandle() : base(true) { }
protected override bool ReleaseHandle()
{
return NativeMethods.CloseHandle(handle);
}
}
@@ -278,7 +337,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Interop
[DllImport("advapi32.dll", SetLastError = true)]
[return: MarshalAs(UnmanagedType.Bool)]
public static extern bool OpenProcessToken(IntPtr processHandle, uint desiredAccess, out IntPtr tokenHandle);
public static extern bool OpenProcessToken(IntPtr processHandle, uint desiredAccess, out SafeTokenHandle tokenHandle);
[DllImport("advapi32.dll", SetLastError = true, CharSet = CharSet.Unicode)]
[return: MarshalAs(UnmanagedType.Bool)]
@@ -286,7 +345,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Interop
[DllImport("advapi32.dll", SetLastError = true)]
[return: MarshalAs(UnmanagedType.Bool)]
public static extern bool AdjustTokenPrivileges(IntPtr tokenHandle, bool disableAllPrivileges,
public static extern bool AdjustTokenPrivileges(SafeTokenHandle tokenHandle, bool disableAllPrivileges,
ref TOKEN_PRIVILEGES newState, uint bufferLength, out TOKEN_PRIVILEGES previousState, out uint returnLength);
[DllImport("kernel32.dll", SetLastError = true)]
@@ -183,4 +183,13 @@ public class ConfigRedactionTests
public void CanViewSecrets_PureDelegatedReader_False()
=> Assert.False(GetTenantConfigurationFunction.CanViewSecrets(
new RequestContext { IsDelegatedReader = true, TenantId = TenantA, TargetTenantId = TenantB }));
[Theory]
[InlineData(Constants.TenantRoles.Operator)]
[InlineData(Constants.TenantRoles.Viewer)]
public void CanViewSecrets_OwnTenantOperatorOrViewer_False(string role)
// Member-tier read-only Settings view (config GET is MemberRead): an own-tenant
// Operator/Viewer arrives with IsTenantAdmin=false ⇒ always the redacted copy.
=> Assert.False(GetTenantConfigurationFunction.CanViewSecrets(
new RequestContext { UserRole = role, TenantId = TenantA, TargetTenantId = TenantA }));
}
@@ -324,4 +324,42 @@ public class CrossTenantAccessTests
Assert.NotNull(entry);
Assert.Equal(TenantScoping.QueryParam, entry.TenantScoping);
}
// ── Cross-tenant rule WRITES: global/rules/{gather,analyze}/{ruleId} ──
// Bug (2026-07-24): a Global Admin editing a foreign tenant's rule via the JWT-scoped
// rules/{type}/{ruleId} route silently upserted into the GA's OWN tenant (200 → false "saved");
// the reload re-read the foreign tenant and showed the unchanged rule. Fix: dedicated cross-tenant
// write routes. They MUST be GlobalAdminOnly (a write tier → no delegated-read rescue, so a
// tenant-admin / delegated caller can never cross into another tenant's rules) + QueryParam (the
// tenant is named by ?tenantId=, not the {ruleId} segment). A downgrade to a tenant tier, or a slip
// to TenantScoping.None (which would ignore ?tenantId= and re-introduce the home-tenant misroute),
// is caught here.
[Theory]
[InlineData("PUT", "global/rules/gather/{ruleId}")]
[InlineData("DELETE", "global/rules/gather/{ruleId}")]
[InlineData("PUT", "global/rules/analyze/{ruleId}")]
[InlineData("DELETE", "global/rules/analyze/{ruleId}")]
public void GlobalRuleWriteRoutes_AreGlobalAdminOnly_QueryParam(string httpMethod, string routeTemplate)
{
var entry = EndpointAccessPolicyCatalog.Entries
.FirstOrDefault(e => e.HttpMethod == httpMethod.ToUpperInvariant() && e.RouteTemplate == routeTemplate);
Assert.NotNull(entry);
Assert.Equal(EndpointPolicy.GlobalAdminOnly, entry!.Policy);
Assert.Equal(TenantScoping.QueryParam, entry.TenantScoping);
}
[Theory]
[InlineData("PUT", "/api/global/rules/gather/rule-1")]
[InlineData("DELETE", "/api/global/rules/gather/rule-1")]
[InlineData("PUT", "/api/global/rules/analyze/rule-1")]
[InlineData("DELETE", "/api/global/rules/analyze/rule-1")]
public void GlobalRuleWrite_CrossTenant_GlobalAdmin_IsAllowed(string httpMethod, string path)
{
var (isBlocked, targetTenantId) = SimulateQueryParamCheck(httpMethod, path, TenantA, hasGlobalScope: true, queryTenantId: TenantB);
Assert.False(isBlocked, $"Global Admin should reach the target tenant on the cross-tenant write route: {httpMethod} {path}");
Assert.Equal(TenantB, targetTenantId);
}
}
@@ -154,6 +154,80 @@ public class DiagnosticsBlobCascadeDeleterTests
Assert.Equal(0, deleter.HostedDeleteCalls);
}
// ── REAL DeleteAsync, Hosted branch — prefix sweep + belt-and-braces name delete ──
// The RecordingDeleter above replaces DeleteAsync wholesale, so these run the REAL
// base implementation against a fake storage service to pin the sweep behaviour.
private const string SessionGuid = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa";
[Fact]
public async Task RealDeleteAsync_Hosted_SweepsSessionPrefix_AndDeletesManifestBlob()
{
var storage = new RecordingHostedStorage();
var deleter = RealDeleter(storage);
var manifest = ManifestWith(blobName: $"{TenantA}/{Filename}", destination: "Hosted");
manifest.SessionId = SessionGuid;
var outcome = await deleter.DeleteAsync(manifest);
Assert.Equal(DiagnosticsBlobDeleteOutcome.HostedDeleted, outcome);
Assert.Equal((TenantA, SessionGuid), storage.SweptSession);
Assert.Equal($"{TenantA}/{Filename}", storage.DeletedBlobPath);
}
[Fact]
public async Task RealDeleteAsync_Hosted_NonGuidSessionId_SkipsSweep_StillDeletesManifestBlob()
{
// Legacy manifests may carry a malformed/empty SessionId — the sweep must degrade
// to the single-blob delete, never throw into the cascade poison path.
var storage = new RecordingHostedStorage();
var deleter = RealDeleter(storage);
var manifest = ManifestWith(blobName: $"{TenantA}/{Filename}", destination: "Hosted");
manifest.SessionId = "not-a-guid";
var outcome = await deleter.DeleteAsync(manifest);
Assert.Equal(DiagnosticsBlobDeleteOutcome.HostedDeleted, outcome);
Assert.Null(storage.SweptSession);
Assert.Equal($"{TenantA}/{Filename}", storage.DeletedBlobPath);
}
private static DiagnosticsBlobCascadeDeleter RealDeleter(RecordingHostedStorage storage)
// TenantConfigurationService is only consulted on the CustomerSas branch; the
// Hosted-branch tests never reach it, so null keeps the harness Azure-free.
=> new(
storage,
tenantConfig: null!,
Microsoft.Extensions.Logging.Abstractions.NullLogger<DiagnosticsBlobCascadeDeleter>.Instance);
private sealed class RecordingHostedStorage : HostedDiagnosticsBlobService
{
public (string TenantId, string SessionId)? SweptSession { get; private set; }
public string? DeletedBlobPath { get; private set; }
public RecordingHostedStorage()
: base(
new Azure.Storage.Blobs.BlobServiceClient("UseDevelopmentStorage=true"),
Microsoft.Extensions.Logging.Abstractions.NullLogger<HostedDiagnosticsBlobService>.Instance,
usesManagedIdentity: false)
{
}
public override Task<int> DeleteBySessionPrefixAsync(
string tenantId, string sessionId, CancellationToken cancellationToken = default)
{
SweptSession = (tenantId, sessionId);
return Task.FromResult(1);
}
public override Task<bool> DeleteIfExistsAsync(
string blobPath, CancellationToken cancellationToken = default)
{
DeletedBlobPath = blobPath;
return Task.FromResult(true);
}
}
// ── BuildBlobUrl helper (CustomerSas URL construction) ─────────────────────────
[Fact]
@@ -160,9 +160,9 @@ public class EndpointPolicyCatalogCompletenessTests
Assert.NotNull(getConfig);
Assert.NotNull(putConfig);
// GET config read is admin-tier read (own-tenant Admin / GA / read-only GlobalReader);
// PUT config write stays Admin/GA only.
Assert.Equal(EndpointPolicy.TenantAdminOrGlobalReader, getConfig.Policy);
// GET config read is member-tier (Operators/Viewers get the redacted read-only Settings
// view; the handler redacts for every non-admin caller); PUT config write stays Admin/GA only.
Assert.Equal(EndpointPolicy.MemberRead, getConfig.Policy);
Assert.Equal(EndpointPolicy.TenantAdminOrGA, putConfig.Policy);
var getRules = EndpointAccessPolicyCatalog.FindPolicy("GET", "/api/rules/gather");
@@ -338,7 +338,7 @@ public class EndpointPolicyCatalogCompletenessTests
[InlineData("POST", "/api/agent/telemetry", true)] // DeviceOrBootstrapAuth
[InlineData("GET", "/api/bootstrap/validate/ABC123", true)] // PublicAnonymous (param route)
[InlineData("GET", "/api/sessions", false)] // MemberRead
[InlineData("GET", "/api/config/00000000-0000-0000-0000-000000000001", false)] // TenantAdminOrGlobalReader
[InlineData("GET", "/api/config/00000000-0000-0000-0000-000000000001", false)] // MemberRead
[InlineData("GET", "/api/global/sessions", false)] // GlobalReadOrAdmin
[InlineData("GET", "/api/nonexistent", false)] // unregistered → fail-closed
public void SkipsJwtValidation_MatchesPolicyTier(string method, string path, bool expectedExempt)
@@ -35,6 +35,7 @@ public class GetTenantFeatureFlagsPayloadTests
Assert.Equal(new[]
{
"bootstrapTokenEnabled",
"diagnosticsUploadConfigured",
"edition",
"enableIntegrityBypassAnalyzer",
"enableSoftwareInventoryAnalyzer",
@@ -114,6 +115,35 @@ public class GetTenantFeatureFlagsPayloadTests
Assert.DoesNotContain("webhookUrl", json, StringComparison.OrdinalIgnoreCase);
}
// ── diagnosticsUploadConfigured (drives the session-detail "Collect Logs" button) ──
[Theory]
// mode Off (or unset) → never configured, regardless of destination
[InlineData(null, null, null, false)]
[InlineData("Off", "https://x.blob.core.windows.net/c?sv=1", "CustomerSas", false)]
[InlineData("Off", null, "Hosted", false)]
// mode active + Hosted destination → configured without any SAS
[InlineData("OnFailure", null, "Hosted", true)]
[InlineData("Always", null, "Hosted", true)]
// mode active + CustomerSas → configured only when a SAS URL is present
[InlineData("OnFailure", "https://x.blob.core.windows.net/c?sv=1", "CustomerSas", true)]
[InlineData("OnFailure", null, "CustomerSas", false)]
[InlineData("Always", "", null, false)]
public void Payload_DiagnosticsUploadConfigured_ModeAndDestinationMatrix(
string? mode, string? sasUrl, string? destination, bool expected)
{
// Null-forgiving: the model declares these non-nullable with defaults, but table
// round-trips can produce null at runtime — the payload must tolerate that shape.
var element = Serialize(new TenantConfiguration
{
DiagnosticsUploadMode = mode!,
DiagnosticsBlobSasUrl = sasUrl!,
DiagnosticsUploadDestination = destination!,
});
Assert.Equal(expected, element.GetProperty("diagnosticsUploadConfigured").GetBoolean());
}
// ── Edition / trial surface ─────────────────────────────────────────────
[Fact]
@@ -0,0 +1,194 @@
using AutopilotMonitor.Functions.Functions.Reports;
using AutopilotMonitor.Shared.DataAccess;
namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// Tests for the TPM-PSS-unsupported aggregation logic extracted from
/// GetTpmPssUnsupportedFunction. All tests target the pure static BuildAggregatedResult
/// method — no HTTP or DI needed. Aggregation is by serial number (the TPM incompatibility
/// is a per-device condition, not a per-model one — firmware levels differ within a model).
/// </summary>
public class GetTpmPssUnsupportedFunctionTests
{
private static readonly DateTime T0 = new(2026, 3, 1, 12, 0, 0, DateTimeKind.Utc);
// =========================================================================
// Empty / no matching reports
// =========================================================================
[Fact]
public void BuildAggregatedResult_EmptyList_ReturnsEmpty()
{
var (aggregated, totalRawReports) = GetTpmPssUnsupportedFunction.BuildAggregatedResult([]);
Assert.Empty(aggregated);
Assert.Equal(0, totalRawReports);
}
[Fact]
public void BuildAggregatedResult_FiltersTpmPssUnsupportedOnly()
{
var reports = new List<DistressReportEntry>
{
MakeEntry("TpmPssUnsupported", "Lenovo", "ThinkCentre M720q", "SN1", T0),
MakeEntry("DeviceNotRegistered", "Lenovo", "ThinkPad T14", "SN2", T0.AddMinutes(1)),
MakeEntry("AuthCertificateRejected", "HP", "EliteBook", "SN3", T0.AddMinutes(2)),
};
var (aggregated, totalRawReports) = GetTpmPssUnsupportedFunction.BuildAggregatedResult(reports);
Assert.Single(aggregated);
Assert.Equal(1, totalRawReports);
}
// =========================================================================
// Aggregation by serial number
// =========================================================================
[Fact]
public void BuildAggregatedResult_AggregatesBySerialCaseInsensitive()
{
var reports = new List<DistressReportEntry>
{
MakeEntry("TpmPssUnsupported", "Lenovo", "ThinkCentre M720q", "S4SQ8685", T0),
MakeEntry("TpmPssUnsupported", "Lenovo", "ThinkCentre M720q", "s4sq8685", T0.AddMinutes(1)),
MakeEntry("TpmPssUnsupported", "Lenovo", "ThinkCentre M720q", " S4SQ8685 ", T0.AddMinutes(2)),
};
var (aggregated, totalRawReports) = GetTpmPssUnsupportedFunction.BuildAggregatedResult(reports);
Assert.Single(aggregated);
Assert.Equal(3, totalRawReports);
var item = ToDynamic(aggregated[0]);
Assert.Equal(3, (int)item.attemptCount);
}
[Fact]
public void BuildAggregatedResult_DistinctSerials_ProduceDistinctRows()
{
var reports = new List<DistressReportEntry>
{
MakeEntry("TpmPssUnsupported", "Lenovo", "ThinkCentre M720q", "SN1", T0),
MakeEntry("TpmPssUnsupported", "Lenovo", "ThinkCentre M720q", "SN2", T0.AddMinutes(1)),
MakeEntry("TpmPssUnsupported", "Microsoft", "Surface Book", "SN3", T0.AddMinutes(2)),
};
var (aggregated, totalRawReports) = GetTpmPssUnsupportedFunction.BuildAggregatedResult(reports);
Assert.Equal(3, aggregated.Count);
Assert.Equal(3, totalRawReports);
}
[Fact]
public void BuildAggregatedResult_UsesMostRecentManufacturerModel()
{
var reports = new List<DistressReportEntry>
{
MakeEntry("TpmPssUnsupported", "OldMfr", "OldModel", "SN1", T0),
MakeEntry("TpmPssUnsupported", "Lenovo", "ThinkCentre M720q", "SN1", T0.AddMinutes(5)),
};
var (aggregated, _) = GetTpmPssUnsupportedFunction.BuildAggregatedResult(reports);
var item = ToDynamic(aggregated[0]);
Assert.Equal("Lenovo", (string)item.manufacturer);
Assert.Equal("ThinkCentre M720q", (string)item.model);
Assert.Equal("SN1", (string)item.serialNumber);
}
// =========================================================================
// Timestamps
// =========================================================================
[Fact]
public void BuildAggregatedResult_FirstSeenLastSeen_Correct()
{
var t1 = T0;
var t2 = T0.AddHours(1);
var t3 = T0.AddHours(2);
var reports = new List<DistressReportEntry>
{
MakeEntry("TpmPssUnsupported", "Lenovo", "ThinkCentre M720q", "SN1", t2),
MakeEntry("TpmPssUnsupported", "Lenovo", "ThinkCentre M720q", "SN1", t1),
MakeEntry("TpmPssUnsupported", "Lenovo", "ThinkCentre M720q", "SN1", t3),
};
var (aggregated, _) = GetTpmPssUnsupportedFunction.BuildAggregatedResult(reports);
var item = ToDynamic(aggregated[0]);
Assert.Equal(t1, (DateTime)item.firstSeen);
Assert.Equal(t3, (DateTime)item.lastSeen);
}
[Fact]
public void BuildAggregatedResult_OrderedByLastSeenDescending()
{
var reports = new List<DistressReportEntry>
{
MakeEntry("TpmPssUnsupported", "Lenovo", "ThinkCentre M720q", "SN1", T0),
MakeEntry("TpmPssUnsupported", "Lenovo", "ThinkPad X13", "SN2", T0.AddHours(2)),
MakeEntry("TpmPssUnsupported", "Lenovo", "ThinkCentre M720s", "SN3", T0.AddHours(1)),
};
var (aggregated, _) = GetTpmPssUnsupportedFunction.BuildAggregatedResult(reports);
Assert.Equal(3, aggregated.Count);
var first = ToDynamic(aggregated[0]);
var second = ToDynamic(aggregated[1]);
var third = ToDynamic(aggregated[2]);
Assert.True((DateTime)first.lastSeen > (DateTime)second.lastSeen);
Assert.True((DateTime)second.lastSeen > (DateTime)third.lastSeen);
}
// =========================================================================
// Null / empty serial number
// =========================================================================
[Fact]
public void BuildAggregatedResult_NullOrEmptySerials_GroupedIntoSingleBucket()
{
var reports = new List<DistressReportEntry>
{
MakeEntry("TpmPssUnsupported", "Lenovo", "ThinkCentre M720q", null, T0),
MakeEntry("TpmPssUnsupported", "Lenovo", "ThinkCentre M720q", "", T0.AddMinutes(1)),
MakeEntry("TpmPssUnsupported", "Lenovo", "ThinkCentre M720q", " ", T0.AddMinutes(2)),
};
var (aggregated, _) = GetTpmPssUnsupportedFunction.BuildAggregatedResult(reports);
Assert.Single(aggregated);
var item = ToDynamic(aggregated[0]);
Assert.Equal(3, (int)item.attemptCount);
}
// =========================================================================
// Helpers
// =========================================================================
private static DistressReportEntry MakeEntry(
string errorType, string? manufacturer, string? model, string? serial, DateTime ingestedAt) =>
new()
{
TenantId = "test-tenant",
ErrorType = errorType,
Manufacturer = manufacturer,
Model = model,
SerialNumber = serial,
IngestedAt = ingestedAt,
AgentTimestamp = ingestedAt,
};
private static dynamic ToDynamic(object obj)
{
var type = obj.GetType();
var dict = new System.Dynamic.ExpandoObject() as IDictionary<string, object?>;
foreach (var prop in type.GetProperties())
{
dict[prop.Name] = prop.GetValue(obj);
}
return dict;
}
}
@@ -0,0 +1,304 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.RegularExpressions;
using System.Threading;
using System.Threading.Tasks;
using AutopilotMonitor.Functions.DataAccess.TableStorage;
using AutopilotMonitor.Functions.Services;
using Azure;
using Azure.Data.Tables;
using Microsoft.Extensions.Logging.Abstractions;
using Moq;
using Xunit;
namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// Behavioural coverage for <see cref="TableHardwareRejectionNotificationTracker"/> — the dedup
/// store behind the two distress-driven bell notifications (hardware rejection per model,
/// TPM-PSS incompatibility per serial). <see cref="HardwareRejectionNotificationTrackerKeyTests"/>
/// pins the RowKey shape; this file pins what the tracker DOES with those keys.
///
/// CORRECTNESS GUARD: the "one bell ever" promise rests entirely on the 409 branch of
/// <c>AddEntityAsync</c> — a rewrite to Upsert or a get-then-add would silently start ringing
/// the bell on every distress report (a device with an incompatible TPM retries forever, so
/// "every report" means a notification storm, not a second bell).
///
/// The retention sweep is covered here too because <c>DeleteOlderThanAsync</c> prunes BOTH key
/// spaces from the shared table: pruning a <c>tpmpss|</c> row re-arms that device's bell.
/// </summary>
public class HardwareRejectionNotificationTrackerDedupTests
{
private const string TenantId = "77777777-7777-7777-7777-777777777777";
private const string OtherTenantId = "88888888-8888-8888-8888-888888888888";
private const string Serial = "S4SQ8685";
// ── TPM PSS: first bell, then never again ──────────────────────────────────────
[Fact]
public async Task TpmPss_FirstReport_RegistersRowAndReturnsTrue()
{
var harness = new Harness();
var isFirst = await harness.Sut.TryRegisterFirstTpmPssNotificationAsync(TenantId, Serial);
Assert.True(isFirst);
var entity = Assert.Single(harness.Store.Values);
Assert.Equal(TenantId.ToLowerInvariant(), entity.PartitionKey);
Assert.Equal("tpmpss|s4sq8685", entity.RowKey);
// Serialization guard: the row must carry the fields the cleanup sweep and any future
// operator query rely on — losing FirstNotifiedAt would make the row immortal.
Assert.Equal(TenantId, entity.GetString("TenantId"));
Assert.Equal(Serial, entity.GetString("SerialNumber"));
Assert.NotNull(entity.GetDateTime("FirstNotifiedAt"));
}
[Fact]
public async Task TpmPss_SecondReport_SameSerial_ReturnsFalse()
{
var harness = new Harness();
Assert.True(await harness.Sut.TryRegisterFirstTpmPssNotificationAsync(TenantId, Serial));
Assert.False(await harness.Sut.TryRegisterFirstTpmPssNotificationAsync(TenantId, Serial));
Assert.Single(harness.Store);
}
[Theory]
[InlineData("s4sq8685")]
[InlineData(" S4SQ8685 ")]
[InlineData("S4Sq8685")]
public async Task TpmPss_SecondReport_CasingOrWhitespaceVariant_StillDeduped(string variant)
{
var harness = new Harness();
Assert.True(await harness.Sut.TryRegisterFirstTpmPssNotificationAsync(TenantId, Serial));
Assert.False(await harness.Sut.TryRegisterFirstTpmPssNotificationAsync(TenantId, variant));
}
[Fact]
public async Task TpmPss_DifferentSerial_OrDifferentTenant_RingsAgain()
{
var harness = new Harness();
Assert.True(await harness.Sut.TryRegisterFirstTpmPssNotificationAsync(TenantId, Serial));
Assert.True(await harness.Sut.TryRegisterFirstTpmPssNotificationAsync(TenantId, "OTHER123"));
Assert.True(await harness.Sut.TryRegisterFirstTpmPssNotificationAsync(OtherTenantId, Serial));
Assert.Equal(3, harness.Store.Count);
}
[Theory]
[InlineData("", Serial)]
[InlineData(" ", Serial)]
[InlineData(TenantId, "")]
[InlineData(TenantId, " ")]
public async Task TpmPss_BlankInput_ReturnsFalse_WithoutTouchingTheTable(string tenantId, string serial)
{
var harness = new Harness();
Assert.False(await harness.Sut.TryRegisterFirstTpmPssNotificationAsync(tenantId, serial));
Assert.Empty(harness.Store);
harness.Table.Verify(
c => c.AddEntityAsync(It.IsAny<TableEntity>(), It.IsAny<CancellationToken>()),
Times.Never);
}
[Fact]
public async Task TpmPss_UnexpectedTableFailure_ReturnsFalse()
{
// Fail-soft in the SUPPRESSING direction: the write may or may not have landed, so the
// caller must not fire a bell it might fire again on the next report.
var harness = new Harness { AddFailure = new RequestFailedException(503, "ServerBusy") };
Assert.False(await harness.Sut.TryRegisterFirstTpmPssNotificationAsync(TenantId, Serial));
}
// ── Hardware rejection: same contract, disjoint key space ──────────────────────
[Fact]
public async Task HardwareRejection_SecondReport_SameModel_ReturnsFalse()
{
var harness = new Harness();
Assert.True(await harness.Sut.TryRegisterFirstNotificationAsync(TenantId, "Lenovo", "ThinkPad X1"));
Assert.False(await harness.Sut.TryRegisterFirstNotificationAsync(TenantId, "LENOVO", " thinkpad x1 "));
Assert.Single(harness.Store);
}
[Fact]
public async Task HardwareRejection_AndTpmPss_DoNotDedupEachOtherInTheSharedTable()
{
var harness = new Harness();
Assert.True(await harness.Sut.TryRegisterFirstNotificationAsync(TenantId, "Lenovo", Serial));
Assert.True(await harness.Sut.TryRegisterFirstTpmPssNotificationAsync(TenantId, Serial));
Assert.Equal(2, harness.Store.Count);
}
// ── Retention sweep ───────────────────────────────────────────────────────────
[Fact]
public async Task DeleteOlderThanAsync_PrunesTpmPssRows_AndReArmsThatDevicesBell()
{
var harness = new Harness();
Assert.True(await harness.Sut.TryRegisterFirstTpmPssNotificationAsync(TenantId, Serial));
harness.AgeRows(TimeSpan.FromDays(31));
var deleted = await harness.Sut.DeleteOlderThanAsync(DateTime.UtcNow.AddDays(-30));
Assert.Equal(1, deleted);
Assert.Empty(harness.Store);
// A device whose TPM was never fixed keeps reporting; after the retention window it is
// worth one more bell rather than permanent silence.
Assert.True(await harness.Sut.TryRegisterFirstTpmPssNotificationAsync(TenantId, Serial));
}
[Fact]
public async Task DeleteOlderThanAsync_KeepsRowsInsideTheWindow_DedupSurvives()
{
var harness = new Harness();
Assert.True(await harness.Sut.TryRegisterFirstTpmPssNotificationAsync(TenantId, Serial));
Assert.True(await harness.Sut.TryRegisterFirstNotificationAsync(TenantId, "Lenovo", "ThinkPad X1"));
var deleted = await harness.Sut.DeleteOlderThanAsync(DateTime.UtcNow.AddDays(-30));
Assert.Equal(0, deleted);
Assert.Equal(2, harness.Store.Count);
Assert.False(await harness.Sut.TryRegisterFirstTpmPssNotificationAsync(TenantId, Serial));
Assert.False(await harness.Sut.TryRegisterFirstNotificationAsync(TenantId, "Lenovo", "ThinkPad X1"));
}
[Fact]
public async Task DeleteOlderThanAsync_FiltersOnFirstNotifiedAt_NotTimestamp()
{
// Rows are insert-once, so FirstNotifiedAt is the creation time. Timestamp would look
// equivalent but is rewritten by a table restore/copy, which would silently re-arm every
// bell in the tenant at once.
var harness = new Harness();
var cutoff = new DateTime(2026, 7, 25, 12, 0, 0, DateTimeKind.Utc);
await harness.Sut.DeleteOlderThanAsync(cutoff);
var filter = Assert.Single(harness.QueryFilters);
Assert.Equal("FirstNotifiedAt lt datetime'2026-07-25T12:00:00Z'", filter);
}
[Fact]
public async Task DeleteOlderThanAsync_ContinuesAfterASingleRowFailure()
{
var harness = new Harness();
Assert.True(await harness.Sut.TryRegisterFirstTpmPssNotificationAsync(TenantId, Serial));
Assert.True(await harness.Sut.TryRegisterFirstTpmPssNotificationAsync(TenantId, "OTHER123"));
harness.AgeRows(TimeSpan.FromDays(31));
harness.FailDeleteForRowKeys.Add("tpmpss|s4sq8685");
var deleted = await harness.Sut.DeleteOlderThanAsync(DateTime.UtcNow.AddDays(-30));
Assert.Equal(1, deleted);
Assert.Equal("tpmpss|s4sq8685", Assert.Single(harness.Store.Values).RowKey);
}
[Fact]
public async Task DeleteOlderThanAsync_QueryFailure_ReturnsZero()
{
var harness = new Harness { QueryFailure = new RequestFailedException(500, "Boom") };
Assert.Equal(0, await harness.Sut.DeleteOlderThanAsync(DateTime.UtcNow.AddDays(-30)));
}
// ── Harness ───────────────────────────────────────────────────────────────────
private sealed class Harness
{
private static readonly Regex CutoffFilter =
new(@"^FirstNotifiedAt lt datetime'(?<cutoff>[^']+)'$", RegexOptions.Compiled);
public TableHardwareRejectionNotificationTracker Sut { get; }
public Mock<TableClient> Table { get; }
public Dictionary<(string Pk, string Rk), TableEntity> Store { get; } = new();
public List<string> QueryFilters { get; } = new();
public HashSet<string> FailDeleteForRowKeys { get; } = new();
/// <summary>Non-409 failure injected into AddEntityAsync (fail-soft path).</summary>
public Exception? AddFailure { get; init; }
/// <summary>Failure injected into QueryAsync enumeration (sweep fail-soft path).</summary>
public Exception? QueryFailure { get; init; }
public Harness()
{
Table = new Mock<TableClient>();
Table.Setup(c => c.AddEntityAsync(It.IsAny<TableEntity>(), It.IsAny<CancellationToken>()))
.Returns<TableEntity, CancellationToken>((e, _) =>
{
if (AddFailure != null) throw AddFailure;
if (Store.ContainsKey((e.PartitionKey, e.RowKey)))
throw new RequestFailedException(409, "Conflict", "EntityAlreadyExists", null);
Store[(e.PartitionKey, e.RowKey)] = e;
return Task.FromResult(new Mock<Response>().Object);
});
Table.Setup(c => c.QueryAsync<TableEntity>(
It.IsAny<string>(),
It.IsAny<int?>(),
It.IsAny<IEnumerable<string>>(),
It.IsAny<CancellationToken>()))
.Returns<string, int?, IEnumerable<string>, CancellationToken>((filter, _, _, _) =>
{
if (QueryFailure != null) throw QueryFailure;
QueryFilters.Add(filter);
var rows = Store.Values.Where(e => MatchesCutoff(filter, e)).ToList();
return AsyncPageable<TableEntity>.FromPages(new[]
{
Page<TableEntity>.FromValues(rows, null, new Mock<Response>().Object),
});
});
Table.Setup(c => c.DeleteEntityAsync(
It.IsAny<string>(),
It.IsAny<string>(),
It.IsAny<ETag>(),
It.IsAny<CancellationToken>()))
.Returns<string, string, ETag, CancellationToken>((pk, rk, _, _) =>
{
if (FailDeleteForRowKeys.Contains(rk))
throw new RequestFailedException(500, "DeleteFailed");
Store.Remove((pk, rk));
return Task.FromResult(new Mock<Response>().Object);
});
var serviceClient = new Mock<TableServiceClient>();
serviceClient.Setup(s => s.GetTableClient(It.IsAny<string>())).Returns(Table.Object);
Sut = new TableHardwareRejectionNotificationTracker(
new TableStorageService(serviceClient.Object, NullLogger<TableStorageService>.Instance),
NullLogger<TableHardwareRejectionNotificationTracker>.Instance);
}
/// <summary>Backdates every stored row so a retention sweep sees it as expired.</summary>
public void AgeRows(TimeSpan age)
{
foreach (var entity in Store.Values)
entity["FirstNotifiedAt"] = DateTime.UtcNow - age;
}
/// <summary>
/// Minimal server-side emulation of the OData filter the tracker builds. An unrecognised
/// filter matches nothing, so a rewrite that no longer selects expired rows fails loudly
/// here instead of leaving dead rows in production.
/// </summary>
private static bool MatchesCutoff(string filter, TableEntity entity)
{
var match = CutoffFilter.Match(filter ?? string.Empty);
if (!match.Success) return false;
var cutoff = DateTime.Parse(
match.Groups["cutoff"].Value,
null,
System.Globalization.DateTimeStyles.AdjustToUniversal | System.Globalization.DateTimeStyles.AssumeUniversal);
var firstNotified = entity.GetDateTime("FirstNotifiedAt");
return firstNotified.HasValue && firstNotified.Value.ToUniversalTime() < cutoff;
}
}
}
@@ -59,4 +59,35 @@ public class HardwareRejectionNotificationTrackerKeyTests
var key = TableHardwareRejectionNotificationTracker.BuildRowKey("", "");
Assert.Equal("|", key);
}
// =========================================================================
// TPM PSS row keys (same table, "tpmpss|" prefix — dedup per serial)
// =========================================================================
[Fact]
public void BuildTpmPssRowKey_LowercasesTrimsAndPrefixes()
{
var key = TableHardwareRejectionNotificationTracker.BuildTpmPssRowKey(" S4SQ8685 ");
Assert.Equal("tpmpss|s4sq8685", key);
}
[Theory]
[InlineData("S4SQ8685", "s4sq8685")]
[InlineData("s4Sq8685", "S4SQ8685")]
public void BuildTpmPssRowKey_IsCaseInsensitive(string serialA, string serialB)
{
Assert.Equal(
TableHardwareRejectionNotificationTracker.BuildTpmPssRowKey(serialA),
TableHardwareRejectionNotificationTracker.BuildTpmPssRowKey(serialB));
}
[Fact]
public void BuildTpmPssRowKey_DoesNotCollideWithHardwareKeyOfSameText()
{
// A hardware key is "{mfr}|{model}"; the TPM key's fixed "tpmpss|" prefix keeps the
// two key spaces disjoint within the shared table.
var hardwareKey = TableHardwareRejectionNotificationTracker.BuildRowKey("Lenovo", "S4SQ8685");
var tpmKey = TableHardwareRejectionNotificationTracker.BuildTpmPssRowKey("S4SQ8685");
Assert.NotEqual(hardwareKey, tpmKey);
}
}
@@ -4,9 +4,12 @@ using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using AutopilotMonitor.Functions.Services.Diagnostics;
using Azure;
using Azure.Storage.Blobs;
using Azure.Storage.Blobs.Models;
using Azure.Storage.Sas;
using Microsoft.Extensions.Logging.Abstractions;
using Moq;
using Xunit;
namespace AutopilotMonitor.Functions.Tests;
@@ -172,6 +175,119 @@ public class HostedDiagnosticsBlobServiceTests
Assert.StartsWith(TenantB + "/", b.BlobPath);
}
// ── DeleteBySessionPrefixAsync — cascade sweep for multi-package sessions ──────
private const string SessionA = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa";
[Theory]
[InlineData("not-a-guid", SessionA)]
[InlineData(TenantA, "not-a-guid")]
public async Task DeleteBySessionPrefix_RejectsNonGuidInputs(string tenantId, string sessionId)
{
var svc = new PrefixDeleteFake(containerExists: true);
await Assert.ThrowsAsync<ArgumentException>(
() => svc.DeleteBySessionPrefixAsync(tenantId, sessionId));
}
[Fact]
public async Task DeleteBySessionPrefix_MissingContainer_IsNoOp()
{
var svc = new PrefixDeleteFake(containerExists: false);
var deleted = await svc.DeleteBySessionPrefixAsync(TenantA, SessionA);
Assert.Equal(0, deleted);
Assert.Null(svc.CapturedPrefix);
}
[Fact]
public async Task DeleteBySessionPrefix_UsesSessionScopedPrefix_AndDeletesEveryMatch()
{
// Two packages for the session: an on-demand (server-requested) one and the terminal
// one. The sweep must issue a delete for BOTH even though the Sessions row (and thus
// the deletion manifest) only references the last.
var svc = new PrefixDeleteFake(
containerExists: true,
blobNames: new[]
{
$"{TenantA}/AgentDiagnostics-{SessionA}-20260724T100000-server-requested.zip",
$"{TenantA}/AgentDiagnostics-{SessionA}-20260724T113000.zip",
});
var deleted = await svc.DeleteBySessionPrefixAsync(TenantA, SessionA);
Assert.Equal(2, deleted);
Assert.Equal($"{TenantA}/AgentDiagnostics-{SessionA}-", svc.CapturedPrefix);
Assert.Equal(2, svc.DeletedBlobNames.Count);
Assert.All(svc.DeletedBlobNames, n => Assert.StartsWith(svc.CapturedPrefix!, n));
}
[Fact]
public async Task DeleteBySessionPrefix_NoMatches_ReturnsZero()
{
var svc = new PrefixDeleteFake(containerExists: true, blobNames: Array.Empty<string>());
var deleted = await svc.DeleteBySessionPrefixAsync(TenantA, SessionA);
Assert.Equal(0, deleted);
Assert.Equal($"{TenantA}/AgentDiagnostics-{SessionA}-", svc.CapturedPrefix);
}
/// <summary>
/// Overrides <c>GetContainerClient</c> with a Moq'd <see cref="BlobContainerClient"/> whose
/// enumeration returns a canned page for the CAPTURED prefix. Prefix filtering itself is
/// the Azure SDK's contract — these tests pin what prefix we ask for and that every
/// returned item is deleted.
/// </summary>
private sealed class PrefixDeleteFake : HostedDiagnosticsBlobService
{
private readonly Mock<BlobContainerClient> _container;
public string? CapturedPrefix { get; private set; }
public List<string> DeletedBlobNames { get; } = new();
public PrefixDeleteFake(bool containerExists, string[]? blobNames = null)
: base(
new BlobServiceClient("UseDevelopmentStorage=true"),
NullLogger<HostedDiagnosticsBlobService>.Instance,
usesManagedIdentity: false)
{
_container = new Mock<BlobContainerClient>();
_container
.Setup(c => c.ExistsAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(Response.FromValue(containerExists, Mock.Of<Response>()));
_container
.Setup(c => c.GetBlobsAsync(
It.IsAny<BlobTraits>(), It.IsAny<BlobStates>(), It.IsAny<string?>(), It.IsAny<CancellationToken>()))
.Returns((BlobTraits _, BlobStates _, string? prefix, CancellationToken _) =>
{
CapturedPrefix = prefix;
var items = (blobNames ?? Array.Empty<string>())
.Select(n => BlobsModelFactory.BlobItem(name: n))
.ToList();
return AsyncPageable<BlobItem>.FromPages(new[]
{
Page<BlobItem>.FromValues(items, continuationToken: null, Mock.Of<Response>()),
});
});
_container
.Setup(c => c.GetBlobClient(It.IsAny<string>()))
.Returns((string name) =>
{
var blob = new Mock<BlobClient>();
blob
.Setup(b => b.DeleteIfExistsAsync(
It.IsAny<DeleteSnapshotsOption>(), It.IsAny<BlobRequestConditions>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(() =>
{
DeletedBlobNames.Add(name);
return Response.FromValue(true, Mock.Of<Response>());
});
return blob.Object;
});
}
protected override BlobContainerClient GetContainerClient() => _container.Object;
}
// ── Constants ───────────────────────────────────────────────────────────────────
[Fact]
@@ -0,0 +1,128 @@
using AutopilotMonitor.Functions.Services;
using AutopilotMonitor.Shared.Models;
namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// Session 8e4cc4ae (2026-07-24): two on-demand ("Collect Logs") uploads landed in hosted
/// storage, but the portal never flipped to Download — the agent's ServerActionDispatcher
/// confirms the upload via <c>server_action_executed(actionType=request_diagnostics,
/// blobName=…)</c> and emits NO <c>diagnostics_uploaded</c> (only the terminal path does),
/// so the Sessions row was never stamped. These tests pin the classification predicate and
/// the destination inference that close that gap backend-side (no agent release needed).
/// </summary>
public class IngestOnDemandDiagnosticsTests
{
private const string TenantId = "b54dc1af-5320-4f60-b5d4-821e0cf2a359";
private const string HostedBlob =
TenantId + "/AgentDiagnostics-8e4cc8ae-1111-2222-3333-444455556666-20260724170902-server-requested.zip";
private static EnrollmentEvent Evt(
string eventType = "server_action_executed",
string? actionType = "request_diagnostics",
string? blobName = HostedBlob)
{
var data = new Dictionary<string, object>();
if (actionType != null) data["actionType"] = actionType;
if (blobName != null) data["blobName"] = blobName;
return new EnrollmentEvent { EventType = eventType, Data = data };
}
// -------- IsOnDemandDiagnosticsUploadConfirmation --------
[Fact]
public void ExecutedRequestDiagnosticsWithBlobName_IsConfirmation()
{
Assert.True(EventIngestProcessor.IsOnDemandDiagnosticsUploadConfirmation(Evt()));
}
[Fact]
public void ActionTypeIsCaseInsensitive()
{
Assert.True(EventIngestProcessor.IsOnDemandDiagnosticsUploadConfirmation(
Evt(actionType: "Request_Diagnostics")));
}
[Theory]
[InlineData("server_action_received")]
[InlineData("server_action_failed")]
[InlineData("diagnostics_uploaded")]
public void OtherEventTypes_AreNotConfirmations(string eventType)
{
Assert.False(EventIngestProcessor.IsOnDemandDiagnosticsUploadConfirmation(Evt(eventType: eventType)));
}
[Theory]
[InlineData("rotate_config")]
[InlineData("terminate_session")]
[InlineData(null)]
public void OtherActionTypes_AreNotConfirmations(string? actionType)
{
Assert.False(EventIngestProcessor.IsOnDemandDiagnosticsUploadConfirmation(Evt(actionType: actionType)));
}
[Theory]
[InlineData(null)]
[InlineData("")]
public void MissingOrEmptyBlobName_IsNotAConfirmation(string? blobName)
{
// A failed upload never reaches server_action_executed, but an executed event without
// blobName (defensive) must not wipe/stamp the row either.
Assert.False(EventIngestProcessor.IsOnDemandDiagnosticsUploadConfirmation(Evt(blobName: blobName)));
}
[Fact]
public void NullEventOrData_IsNotAConfirmation()
{
Assert.False(EventIngestProcessor.IsOnDemandDiagnosticsUploadConfirmation(null));
// Data is non-nullable on the model but the JSON deserializer can still hand us null
// for a payload without "data" — null! pins the guard the predicate actually has.
Assert.False(EventIngestProcessor.IsOnDemandDiagnosticsUploadConfirmation(
new EnrollmentEvent { EventType = "server_action_executed", Data = null! }));
}
// -------- InferDiagnosticsDestination --------
[Fact]
public void ExplicitDestination_AlwaysWins()
{
Assert.Equal("CustomerSas",
EventIngestProcessor.InferDiagnosticsDestination("CustomerSas", HostedBlob, TenantId));
}
[Fact]
public void TenantPrefixedBlobName_InfersHosted()
{
Assert.Equal("Hosted",
EventIngestProcessor.InferDiagnosticsDestination(null, HostedBlob, TenantId));
}
[Fact]
public void TenantPrefixMatch_IsCaseInsensitive()
{
Assert.Equal("Hosted",
EventIngestProcessor.InferDiagnosticsDestination(null, HostedBlob.ToUpperInvariant(), TenantId));
}
[Fact]
public void BareFilename_InfersNothing()
{
// CustomerSas uploads persist the bare zip filename — repo must leave the
// destination column unchanged (read-time default CustomerSas).
Assert.Null(EventIngestProcessor.InferDiagnosticsDestination(
null, "AgentDiagnostics-x-20260724.zip", TenantId));
}
[Fact]
public void ForeignTenantPrefix_InfersNothing()
{
Assert.Null(EventIngestProcessor.InferDiagnosticsDestination(
null, "99999999-9999-9999-9999-999999999999/x.zip", TenantId));
}
[Fact]
public void NullBlobName_InfersNothing()
{
Assert.Null(EventIngestProcessor.InferDiagnosticsDestination(null, null, TenantId));
}
}
@@ -1,4 +1,5 @@
using System.Security.Claims;
using AutopilotMonitor.Functions.Functions.Config;
using AutopilotMonitor.Functions.Middleware;
using AutopilotMonitor.Functions.Security;
using AutopilotMonitor.Functions.Services;
@@ -205,8 +206,101 @@ public class PolicyEnforcementMiddlewareTests
Assert.Equal(403, result.StatusCode);
}
// ── config GET (TenantAdminOrGlobalReader): own-tenant admin view vs reader view ──
// GetTenantConfigurationFunction redacts only when IsGlobalReader && !(IsTenantAdmin && Target==Tenant).
// ── TenantAdminOrOperator (POST sessions/{id}/actions — "Collect Logs") ────────
private const string SessionA = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa";
private static void AsTenantMemberRole(Harness h, string tenantId, string upn, string role) =>
h.Repo.Setup(r => r.GetTenantMemberAsync(It.IsAny<string>(), It.IsAny<string>()))
.ReturnsAsync(new TenantMember { Upn = upn, TenantId = tenantId, Role = role, IsEnabled = true });
[Fact]
public async Task SessionActions_TenantOperator_IsAllowed_WithOperatorRoleOnContext()
{
const string upn = "operator@contoso.com";
var h = BuildHarness();
AsTenantMemberRole(h, TenantA, upn, Constants.TenantRoles.Operator);
var result = await h.Middleware.DecideAsync(
"POST", $"/api/sessions/{SessionA}/actions", null, AuthedPrincipal(TenantA, upn));
Assert.True(result.Allowed);
// The function's per-type gate depends on these exact flags: an Operator must arrive
// with IsTenantAdmin=false + UserRole=Operator so terminate_session/rotate_config 403.
Assert.False(result.Context!.IsTenantAdmin);
Assert.Equal(Constants.TenantRoles.Operator, result.Context!.UserRole);
}
[Fact]
public async Task SessionActions_TenantAdmin_IsAllowed_WithAdminFlag()
{
const string upn = "admin@contoso.com";
var h = BuildHarness();
h.AsTenantAdmin(TenantA, upn);
var result = await h.Middleware.DecideAsync(
"POST", $"/api/sessions/{SessionA}/actions", null, AuthedPrincipal(TenantA, upn));
Assert.True(result.Allowed);
Assert.True(result.Context!.IsTenantAdmin);
}
[Fact]
public async Task SessionActions_TenantViewer_IsForbidden()
{
const string upn = "viewer@contoso.com";
var h = BuildHarness();
AsTenantMemberRole(h, TenantA, upn, Constants.TenantRoles.Viewer);
var result = await h.Middleware.DecideAsync(
"POST", $"/api/sessions/{SessionA}/actions", null, AuthedPrincipal(TenantA, upn));
Assert.False(result.Allowed);
Assert.Equal(403, result.StatusCode);
}
[Fact]
public async Task SessionActions_NonMember_IsForbidden()
{
var h = BuildHarness(); // defaults: no membership, no global role
var result = await h.Middleware.DecideAsync(
"POST", $"/api/sessions/{SessionA}/actions", null, AuthedPrincipal(TenantA, "outsider@contoso.com"));
Assert.False(result.Allowed);
Assert.Equal(403, result.StatusCode);
}
[Fact]
public async Task SessionActions_GlobalAdmin_IsAllowed()
{
const string upn = "ga@platform.com";
var h = BuildHarness();
h.AsGlobalRole(Constants.GlobalRoles.GlobalAdmin);
var result = await h.Middleware.DecideAsync(
"POST", $"/api/sessions/{SessionA}/actions", null, AuthedPrincipal(TenantA, upn));
Assert.True(result.Allowed);
Assert.True(result.Context!.IsGlobalAdmin);
}
[Fact]
public async Task SessionActions_PureGlobalReader_IsForbidden_WriteTier()
{
const string upn = "reader@platform.com";
var h = BuildHarness();
h.AsGlobalRole(Constants.GlobalRoles.GlobalReader);
var result = await h.Middleware.DecideAsync(
"POST", $"/api/sessions/{SessionA}/actions", null, AuthedPrincipal(TenantA, upn));
Assert.False(result.Allowed);
Assert.Equal(403, result.StatusCode);
}
// ── config GET (MemberRead): own-tenant admin view vs redacted reader/member view ──
// GetTenantConfigurationFunction redacts unless GA or own-tenant Admin (CanViewSecrets).
// These lock in the RequestContext that drives that decision.
[Fact]
@@ -243,6 +337,38 @@ public class PolicyEnforcementMiddlewareTests
Assert.Equal(TenantB, rc.TargetTenantId);
}
[Fact]
public async Task ConfigGet_OwnTenantOperator_IsAllowed_RedactedMemberView()
{
// Read-only Settings view for troubleshooting staff: an own-tenant Operator is admitted
// (MemberRead) but arrives with IsTenantAdmin=false, so CanViewSecrets ⇒ redacted copy.
const string upn = "operator@contoso.com";
var h = BuildHarness();
AsTenantMemberRole(h, TenantA, upn, Constants.TenantRoles.Operator);
var result = await h.Middleware.DecideAsync("GET", $"/api/config/{TenantA}", null, AuthedPrincipal(TenantA, upn));
Assert.True(result.Allowed);
var rc = result.Context!;
Assert.False(rc.IsTenantAdmin);
Assert.False(GetTenantConfigurationFunction.CanViewSecrets(rc));
}
[Fact]
public async Task ConfigGet_CrossTenantOperator_IsForbidden()
{
// Membership is resolved against the caller's JWT tenant — an Operator of A naming
// tenant B in the route is a cross-tenant read and must stay 403.
const string upn = "operator@contoso.com";
var h = BuildHarness();
AsTenantMemberRole(h, TenantA, upn, Constants.TenantRoles.Operator);
var result = await h.Middleware.DecideAsync("GET", $"/api/config/{TenantB}", null, AuthedPrincipal(TenantA, upn));
Assert.False(result.Allowed);
Assert.Equal(403, result.StatusCode);
}
// ── Cross-tenant read: GlobalReader crosses tenants (HasGlobalScope gate) ───────
[Fact]
@@ -1,3 +1,4 @@
using AutopilotMonitor.Functions.Functions.Sessions;
using AutopilotMonitor.Functions.Services;
using AutopilotMonitor.Shared.Models;
using Newtonsoft.Json;
@@ -11,6 +12,35 @@ namespace AutopilotMonitor.Functions.Tests;
/// </summary>
public class ServerActionQueueTests
{
// ── Per-type caller gate (route tier is TenantAdminOrOperator; the function must
// re-gate admin-only types for Operators) ─────────────────────────────────────
[Theory]
// Operator (neither admin flag): only request_diagnostics
[InlineData(ServerActionTypes.RequestDiagnostics, false, false, true)]
[InlineData(ServerActionTypes.TerminateSession, false, false, false)]
[InlineData(ServerActionTypes.RotateConfig, false, false, false)]
// Tenant Admin: everything
[InlineData(ServerActionTypes.TerminateSession, true, false, true)]
[InlineData(ServerActionTypes.RotateConfig, true, false, true)]
[InlineData(ServerActionTypes.RequestDiagnostics, true, false, true)]
// Global Admin: everything
[InlineData(ServerActionTypes.TerminateSession, false, true, true)]
[InlineData(ServerActionTypes.RotateConfig, false, true, true)]
public void IsTypeAllowedForCaller_Matrix(
string actionType, bool isTenantAdmin, bool isGlobalAdmin, bool expected)
{
Assert.Equal(expected,
QueueSessionActionFunction.IsTypeAllowedForCaller(actionType, isTenantAdmin, isGlobalAdmin));
}
[Fact]
public void IsTypeAllowedForCaller_IsCaseInsensitive_ForOperator()
{
Assert.True(QueueSessionActionFunction.IsTypeAllowedForCaller(
"Request_Diagnostics", isTenantAdmin: false, isGlobalAdmin: false));
}
[Fact]
public void DeserializePendingActions_NullOrEmpty_ReturnsEmpty()
{
@@ -13,6 +13,7 @@ public class TenantNotificationAudienceCatalogTests
{
[Theory]
[InlineData("hardware_rejection", NotificationAudience.Admin)]
[InlineData("tpm_pss_unsupported", NotificationAudience.Admin)]
[InlineData("sla_breach", NotificationAudience.Member)]
[InlineData("sla_consecutive_failures", NotificationAudience.Member)]
[InlineData("sla_resolved", NotificationAudience.Member)]
@@ -9,7 +9,10 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
{
/// <summary>
/// Table Storage implementation of <see cref="IHardwareRejectionNotificationTracker"/>.
/// PartitionKey = tenantId (lowercased), RowKey = "{manufacturer-lower}|{model-lower}" (trimmed).
/// PartitionKey = tenantId (lowercased). RowKey encodes the dedup subject:
/// - hardware rejection: "{manufacturer-lower}|{model-lower}" (trimmed)
/// - TPM PSS unsupported: "tpmpss|{serial-lower}" (trimmed; the literal "tpmpss" prefix
/// cannot collide with a hardware key because no real manufacturer is named "tpmpss")
/// Race-safe via AddEntityAsync: Azure Table Storage returns 409 Conflict if the entity already exists.
/// </summary>
public class TableHardwareRejectionNotificationTracker : IHardwareRejectionNotificationTracker
@@ -68,14 +71,54 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
}
}
public async Task<bool> TryRegisterFirstTpmPssNotificationAsync(string tenantId, string serialNumber)
{
if (string.IsNullOrWhiteSpace(tenantId) || string.IsNullOrWhiteSpace(serialNumber))
return false;
var partitionKey = tenantId.ToLowerInvariant();
var rowKey = BuildTpmPssRowKey(serialNumber);
var entity = new TableEntity(partitionKey, rowKey)
{
["TenantId"] = tenantId,
["SerialNumber"] = serialNumber,
["FirstNotifiedAt"] = DateTime.UtcNow
};
try
{
await _table.AddEntityAsync(entity);
_logger.LogInformation(
"TpmPssUnsupported tracker registered: tenant={TenantId} sn={SerialNumber}",
tenantId, serialNumber);
return true;
}
catch (RequestFailedException ex) when (ex.Status == 409)
{
// Already notified for this (tenant, serial) — no second bell.
return false;
}
catch (Exception ex)
{
_logger.LogWarning(ex,
"TpmPssUnsupported tracker failed: tenant={TenantId} sn={SerialNumber}",
tenantId, serialNumber);
// On unexpected failure, return false so we do not double-fire if the row was actually written.
return false;
}
}
public async Task<int> DeleteOlderThanAsync(DateTime cutoffUtc)
{
try
{
// Rows are insert-once (AddEntityAsync, never updated), so FirstNotifiedAt == creation time.
// Pruning here resets the lifetime dedup for a given model: if the same model is rejected
// again after the cutoff, the bell fires once more — acceptable, since the portal only
// surfaces recent rejections anyway.
// Pruning here resets the lifetime dedup for BOTH key spaces in this table: a hardware
// model rejected again after the cutoff rings once more, and so does a TPM-PSS device
// that reports again ("tpmpss|{serial}" rows are pruned by the same sweep). Acceptable
// in both cases — the portal only surfaces recent rejections anyway, and a device whose
// TPM was never fixed is worth surfacing again after a month of silence.
var filter = $"FirstNotifiedAt lt datetime'{cutoffUtc:yyyy-MM-ddTHH:mm:ss}Z'";
var query = _table.QueryAsync<TableEntity>(filter: filter, select: new[] { "PartitionKey", "RowKey" });
@@ -111,5 +154,11 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
var mdl = (model ?? string.Empty).Trim().ToLowerInvariant();
return $"{mfr}|{mdl}";
}
internal static string BuildTpmPssRowKey(string serialNumber)
{
var sn = (serialNumber ?? string.Empty).Trim().ToLowerInvariant();
return $"tpmpss|{sn}";
}
}
}
@@ -29,7 +29,8 @@ namespace AutopilotMonitor.Functions.Functions.Config
{
try
{
// Authentication + TenantAdminOrGA authorization enforced by PolicyEnforcementMiddleware
// Authentication + MemberRead authorization enforced by PolicyEnforcementMiddleware —
// Operators/Viewers are admitted for a READ-ONLY view (secrets redacted below).
var requestCtx = req.GetRequestContext();
var userIdentifier = requestCtx.UserPrincipalName;
@@ -39,8 +40,9 @@ namespace AutopilotMonitor.Functions.Functions.Config
// Only a caller with WRITE authority over THIS target tenant sees the full config including
// DiagnosticsBlobSasUrl / webhook URLs / custom headers (they manage those in the Settings
// UI). Everyone else admitted to this read tier — a cross-tenant Global Reader, or a
// delegated reader viewing one of their managed tenants — gets the secrets redacted (returns
// UI). Everyone else admitted to this read tier — an own-tenant Operator/Viewer, a
// cross-tenant Global Reader, or a delegated reader viewing one of their managed
// tenants — gets the secrets redacted (returns
// a copy; the cached instance is never mutated). Redact-by-default: only Global Admin (any
// tenant) or the tenant's OWN admin clears secrets. The own-tenant check (target == JWT
// tenant) is what keeps a delegated reader — whose target is a DIFFERENT tenant — redacted
@@ -66,8 +68,9 @@ namespace AutopilotMonitor.Functions.Functions.Config
/// <summary>
/// Redact-by-default secret clearance for the config GET. Only a caller with WRITE authority over
/// THIS target tenant sees the unredacted secrets (SAS URLs, webhook URLs, custom headers): a Global
/// Admin (any tenant), or the tenant's OWN admin. Everyone else admitted to this read tier — a
/// cross-tenant Global Reader, or a delegated reader viewing one of their managed tenants — is
/// Admin (any tenant), or the tenant's OWN admin. Everyone else admitted to this read tier — an
/// own-tenant Operator/Viewer (read-only Settings view), a cross-tenant Global Reader, or a
/// delegated reader viewing one of their managed tenants — is
/// redacted. The own-tenant check (target == JWT tenant) is what keeps a delegated reader redacted
/// even when they are an admin of their own home tenant, since their target is a DIFFERENT tenant.
/// Extracted as a pure static so the security decision is unit-testable without an HttpRequestData mock.
@@ -76,6 +76,14 @@ namespace AutopilotMonitor.Functions.Functions.Config
return new
{
bootstrapTokenEnabled = config.BootstrapTokenEnabled,
// Session-detail "Collect Logs" button: whether an on-demand diagnostics upload can
// succeed right now (mode not Off + a usable destination). Members below Admin cannot
// read the full config, so this boolean is their only signal. Deliberately exposes no
// destination detail — just "would an upload work".
diagnosticsUploadConfigured =
!string.Equals(config.DiagnosticsUploadMode ?? "Off", "Off", StringComparison.OrdinalIgnoreCase)
&& GetAgentConfigFunction.ResolveDiagnosticsUploadEnabled(
config.DiagnosticsBlobSasUrl, config.DiagnosticsUploadDestination),
// Drives the "Autopilot Device Validation disabled" dashboard banner
// (useTenantSecurityConfig).
validateAutopilotDevice = config.ValidateAutopilotDevice,
@@ -269,6 +269,36 @@ namespace AutopilotMonitor.Functions.Functions.Ingest
}
}
// In-app bell notification on first-ever TPM-PSS incompatibility per (tenant, serial).
// Same lifetime-dedup pattern as the hardware-rejection bell above. No webhook —
// volume is tiny and the remediation (TPM firmware update / device replacement)
// is not time-critical. Reports without a serial number cannot be deduped and
// fire no bell; they still appear in the tenant insights panel.
if (report.ErrorType == DistressErrorType.TpmPssUnsupported
&& !string.IsNullOrEmpty(serialNumber))
{
try
{
var isFirst = await _hardwareBellTracker
.TryRegisterFirstTpmPssNotificationAsync(tenantId, serialNumber);
if (isFirst)
{
var deviceLabel = string.Join(" ",
new[] { manufacturer, model }.Where(s => !string.IsNullOrEmpty(s)));
await _tenantNotificationService.CreateNotificationAsync(
tenantId,
type: "tpm_pss_unsupported",
title: "Device with incompatible TPM detected",
message: $"Device {serialNumber}{(deviceLabel.Length > 0 ? $" ({deviceLabel})" : "")} reported a TPM that cannot perform RSA-PSS signing — the agent on this device cannot authenticate. A TPM firmware update or device replacement is required.",
href: "/settings/tenant/hardware-whitelist");
}
}
catch (Exception bellEx)
{
_logger.LogWarning(bellEx, "TpmPssUnsupported bell notification failed for tenant {TenantId}", tenantId);
}
}
// Structured log (Warning, not Critical — data is unverified)
_logger.LogWarning(
"AgentDistress [{ErrorType}] tenant={TenantId} mfr={Manufacturer} model={Model} sn={SerialNumber} http={HttpStatusCode} ver={AgentVersion} certState={CertSourceState} thumbprint={CertThumbprint}: {Message}",
@@ -0,0 +1,95 @@
using System;
using System.Collections.Generic;
using System.Linq;
using AutopilotMonitor.Functions.Helpers;
using AutopilotMonitor.Shared.DataAccess;
using Microsoft.Azure.Functions.Worker;
using Microsoft.Azure.Functions.Worker.Http;
using Microsoft.Extensions.Logging;
namespace AutopilotMonitor.Functions.Functions.Reports
{
/// <summary>
/// Tenant-scoped endpoint: returns devices whose TPM-backed client certificate cannot
/// perform RSA-PSS signing (Schannel silently drops the cert from TLS client-auth on
/// Windows 11 25H2+, so the agent can never authenticate), aggregated by serial number.
/// Data comes from distress reports with ErrorType == "TpmPssUnsupported".
///
/// Informational only — the remediation is a TPM firmware update or device replacement,
/// not a portal action. The 14-day horizon is enforced by the distress-report retention
/// cleanup, not by this function.
/// Authentication + MemberRead authorization enforced by PolicyEnforcementMiddleware.
/// </summary>
public class GetTpmPssUnsupportedFunction
{
private readonly ILogger<GetTpmPssUnsupportedFunction> _logger;
private readonly IDistressReportRepository _repository;
public GetTpmPssUnsupportedFunction(
ILogger<GetTpmPssUnsupportedFunction> logger,
IDistressReportRepository repository)
{
_logger = logger;
_repository = repository;
}
[Function("GetTpmPssUnsupported")]
public async Task<HttpResponseData> Run(
[HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = "audit/tpm-pss-unsupported")] HttpRequestData req)
{
try
{
string tenantId = TenantHelper.GetTenantId(req);
var reports = await _repository.GetDistressReportsAsync(tenantId, maxResults: 200);
var (aggregated, totalRawReports) = BuildAggregatedResult(reports);
return await req.OkAsync(new
{
success = true,
aggregated,
totalRawReports,
dataQualityNotice = "This data is from pre-authentication distress reports and is UNVERIFIED. Devices reported here have a TPM that cannot perform RSA-PSS signing, so their agent cannot authenticate to the backend. Serial number, manufacturer, and model values are self-reported by devices."
});
}
catch (Exception ex)
{
return await req.InternalServerErrorAsync(_logger, ex, "Get TPM PSS unsupported devices");
}
}
/// <summary>
/// Filters distress reports to TpmPssUnsupported and aggregates by serial number.
/// Reports with no serial number are grouped into a single "unknown serial" bucket.
/// Extracted as public static for testability.
/// </summary>
public static (List<object> aggregated, int totalRawReports) BuildAggregatedResult(
List<DistressReportEntry> reports)
{
var tpmReports = reports
.Where(r => string.Equals(r.ErrorType, "TpmPssUnsupported", StringComparison.OrdinalIgnoreCase))
.ToList();
var aggregated = tpmReports
.GroupBy(r => (r.SerialNumber ?? "").Trim().ToLowerInvariant())
.Select(g =>
{
var mostRecent = g.OrderByDescending(r => r.IngestedAt).First();
return new
{
serialNumber = mostRecent.SerialNumber ?? "",
manufacturer = mostRecent.Manufacturer ?? "",
model = mostRecent.Model ?? "",
attemptCount = g.Count(),
firstSeen = g.Min(r => r.IngestedAt),
lastSeen = g.Max(r => r.IngestedAt)
};
})
.OrderByDescending(a => a.lastSeen)
.ToList<object>();
return (aggregated, tpmReports.Count);
}
}
}
@@ -10,8 +10,8 @@ using Microsoft.Extensions.Logging;
namespace AutopilotMonitor.Functions.Functions.Reports
{
/// <summary>
/// Tenant-admin entry point for submitting diagnostic files (logs, JSON state files,
/// screenshots) without a session context. Lands in the same SessionReports table +
/// Tenant-admin/-operator entry point for submitting diagnostic files (logs, JSON state
/// files, screenshots) without a session context. Lands in the same SessionReports table +
/// session-reports blob container as session reports, distinguished by
/// <see cref="SessionReportMetadata.ReportType"/> = "diagFiles".
/// </summary>
@@ -45,7 +45,7 @@ namespace AutopilotMonitor.Functions.Functions.Reports
try
{
// Authentication + TenantAdminOrGA authorization enforced by PolicyEnforcementMiddleware
// Authentication + TenantAdminOrOperator authorization enforced by PolicyEnforcementMiddleware
var requestCtx = req.GetRequestContext();
var tenantId = requestCtx.TenantId;
var userIdentifier = requestCtx.UserPrincipalName;
@@ -1,14 +1,22 @@
using System.Net;
using AutopilotMonitor.Functions.Services;
using AutopilotMonitor.Shared.Models;
using Microsoft.Azure.Functions.Worker;
using Microsoft.Azure.Functions.Worker.Http;
using Microsoft.Extensions.Logging;
using Newtonsoft.Json;
namespace AutopilotMonitor.Functions.Functions.Rules
{
/// <summary>
/// Global Admin endpoints for viewing rules across tenants.
/// Reads tenantId from query string (not JWT) so global admins can inspect any tenant.
/// Global Admin endpoints for viewing AND mutating rules across tenants.
/// Reads tenantId from the ?tenantId= query string (not JWT) so a Global Admin can inspect
/// or edit any tenant. The write routes exist because the JWT-scoped GatherRulesFunction /
/// AnalyzeRulesFunction resolve the tenant from the caller's own token: a GA editing a foreign
/// tenant via those routes would silently upsert into its HOME tenant (200 → false "saved"),
/// while the reload re-reads the foreign tenant and shows the unchanged rule. These routes are
/// GlobalAdminOnly + TenantScoping.QueryParam so cross-tenant write stays platform-admin-only
/// (no tenant-admin / delegated rescue — the delegated grant applies to READ tiers only).
/// </summary>
public class GlobalRulesFunction
{
@@ -77,5 +85,163 @@ namespace AutopilotMonitor.Functions.Functions.Rules
await response.WriteAsJsonAsync(new { success = true, rules });
return response;
}
/// <summary>
/// PUT /api/global/rules/gather/{ruleId}?tenantId=X - Update a gather rule in any tenant (Global Admin only).
/// Cross-tenant counterpart of GatherRulesFunction.UpdateRule; the tenant comes from the query, not the JWT.
/// </summary>
[Function("UpdateGlobalGatherRule")]
public async Task<HttpResponseData> UpdateGatherRule(
[HttpTrigger(AuthorizationLevel.Anonymous, "put", Route = "global/rules/gather/{ruleId}")] HttpRequestData req,
string ruleId)
{
// Authentication + GlobalAdminOnly authorization enforced by PolicyEnforcementMiddleware.
var tenantId = ResolveTenantId(req, out var tenantError);
if (tenantError != null)
return await BadRequestAsync(req, tenantError);
var (rule, bodyError) = await ReadRuleBodyAsync<GatherRule>(req);
if (bodyError != null)
return await BadRequestAsync(req, bodyError);
rule!.RuleId = ruleId;
var scopeError = GatherRulesFunction.ValidateScopeAndEmitMode(rule);
if (scopeError != null)
return await BadRequestAsync(req, scopeError);
_logger.LogInformation("Global admin updating gather rule {RuleId} for tenant {TenantId}", ruleId, tenantId);
var success = await _gatherRuleService.UpdateRuleAsync(tenantId, rule);
var response = req.CreateResponse(success ? HttpStatusCode.OK : HttpStatusCode.InternalServerError);
await response.WriteAsJsonAsync(new { success, message = success ? "Rule updated" : "Failed to update rule" });
return response;
}
/// <summary>
/// DELETE /api/global/rules/gather/{ruleId}?tenantId=X - Delete a gather rule in any tenant (Global Admin only).
/// </summary>
[Function("DeleteGlobalGatherRule")]
public async Task<HttpResponseData> DeleteGatherRule(
[HttpTrigger(AuthorizationLevel.Anonymous, "delete", Route = "global/rules/gather/{ruleId}")] HttpRequestData req,
string ruleId)
{
// Authentication + GlobalAdminOnly authorization enforced by PolicyEnforcementMiddleware.
var tenantId = ResolveTenantId(req, out var tenantError);
if (tenantError != null)
return await BadRequestAsync(req, tenantError);
// Load the rule to determine its type (built-in/community vs. custom).
var rules = await _gatherRuleService.GetAllRulesForTenantAsync(tenantId);
var rule = rules.FirstOrDefault(r => r.RuleId == ruleId);
if (rule == null)
{
var notFound = req.CreateResponse(HttpStatusCode.NotFound);
await notFound.WriteAsJsonAsync(new { success = false, message = "Rule not found" });
return notFound;
}
_logger.LogInformation("Global admin deleting gather rule {RuleId} for tenant {TenantId}", ruleId, tenantId);
var success = await _gatherRuleService.DeleteRuleAsync(tenantId, rule);
var response = req.CreateResponse(success ? HttpStatusCode.OK : HttpStatusCode.InternalServerError);
await response.WriteAsJsonAsync(new { success, message = success ? "Rule deleted" : "Failed to delete rule" });
return response;
}
/// <summary>
/// PUT /api/global/rules/analyze/{ruleId}?tenantId=X - Update an analyze rule in any tenant (Global Admin only).
/// Cross-tenant counterpart of AnalyzeRulesFunction.UpdateRule.
/// </summary>
[Function("UpdateGlobalAnalyzeRule")]
public async Task<HttpResponseData> UpdateAnalyzeRule(
[HttpTrigger(AuthorizationLevel.Anonymous, "put", Route = "global/rules/analyze/{ruleId}")] HttpRequestData req,
string ruleId)
{
// Authentication + GlobalAdminOnly authorization enforced by PolicyEnforcementMiddleware.
var tenantId = ResolveTenantId(req, out var tenantError);
if (tenantError != null)
return await BadRequestAsync(req, tenantError);
var (rule, bodyError) = await ReadRuleBodyAsync<AnalyzeRule>(req);
if (bodyError != null)
return await BadRequestAsync(req, bodyError);
rule!.RuleId = ruleId;
_logger.LogInformation("Global admin updating analyze rule {RuleId} for tenant {TenantId}", ruleId, tenantId);
var success = await _analyzeRuleService.UpdateRuleAsync(tenantId, rule);
var response = req.CreateResponse(success ? HttpStatusCode.OK : HttpStatusCode.InternalServerError);
await response.WriteAsJsonAsync(new { success, message = success ? "Rule updated" : "Failed to update rule" });
return response;
}
/// <summary>
/// DELETE /api/global/rules/analyze/{ruleId}?tenantId=X - Delete an analyze rule in any tenant (Global Admin only).
/// </summary>
[Function("DeleteGlobalAnalyzeRule")]
public async Task<HttpResponseData> DeleteAnalyzeRule(
[HttpTrigger(AuthorizationLevel.Anonymous, "delete", Route = "global/rules/analyze/{ruleId}")] HttpRequestData req,
string ruleId)
{
// Authentication + GlobalAdminOnly authorization enforced by PolicyEnforcementMiddleware.
var tenantId = ResolveTenantId(req, out var tenantError);
if (tenantError != null)
return await BadRequestAsync(req, tenantError);
// Load the rule to determine its type (built-in/community vs. custom).
var rules = await _analyzeRuleService.GetAllRulesForTenantAsync(tenantId);
var rule = rules.FirstOrDefault(r => r.RuleId == ruleId);
if (rule == null)
{
var notFound = req.CreateResponse(HttpStatusCode.NotFound);
await notFound.WriteAsJsonAsync(new { success = false, message = "Rule not found" });
return notFound;
}
_logger.LogInformation("Global admin deleting analyze rule {RuleId} for tenant {TenantId}", ruleId, tenantId);
var success = await _analyzeRuleService.DeleteRuleAsync(tenantId, rule);
var response = req.CreateResponse(success ? HttpStatusCode.OK : HttpStatusCode.InternalServerError);
await response.WriteAsJsonAsync(new { success, message = success ? "Rule deleted" : "Failed to delete rule" });
return response;
}
/// <summary>
/// Resolves the target tenant from the ?tenantId= query string (the write routes are
/// TenantScoping.QueryParam). Sets <paramref name="error"/> when the parameter is missing.
/// </summary>
private static string ResolveTenantId(HttpRequestData req, out string? error)
{
var tenantId = System.Web.HttpUtility.ParseQueryString(req.Url.Query ?? "").Get("tenantId");
error = string.IsNullOrEmpty(tenantId) ? "tenantId query parameter is required" : null;
return tenantId ?? string.Empty;
}
/// <summary>
/// Reads and deserializes a rule body with the same 1 MB guard the JWT-scoped functions apply.
/// Returns (rule, null) on success or (null, errorMessage) for a 400.
/// </summary>
private static async Task<(T? rule, string? error)> ReadRuleBodyAsync<T>(HttpRequestData req) where T : class
{
if (req.Headers.TryGetValues("Content-Length", out var clValues)
&& long.TryParse(clValues.FirstOrDefault(), out var contentLength)
&& contentLength > 1_048_576) // 1 MB limit
{
return (null, "Request body too large");
}
var body = await new StreamReader(req.Body).ReadToEndAsync();
var rule = JsonConvert.DeserializeObject<T>(body);
return rule == null ? (null, "Invalid rule data") : (rule, null);
}
private static async Task<HttpResponseData> BadRequestAsync(HttpRequestData req, string message)
{
var badRequest = req.CreateResponse(HttpStatusCode.BadRequest);
await badRequest.WriteAsJsonAsync(new { success = false, message });
return badRequest;
}
}
}
@@ -32,6 +32,28 @@ namespace AutopilotMonitor.Functions.Functions.Sessions
ServerActionTypes.RequestDiagnostics
};
/// <summary>
/// Action types an Operator may queue. The route is TenantAdminOrOperator so troubleshooting
/// staff can trigger an on-demand diagnostics collection; everything with config or lifecycle
/// impact (terminate_session, rotate_config) stays Admin/GA-only via <see cref="IsTypeAllowedForCaller"/>.
/// </summary>
private static readonly HashSet<string> OperatorAllowedTypes = new(StringComparer.OrdinalIgnoreCase)
{
ServerActionTypes.RequestDiagnostics
};
/// <summary>
/// Type-level authorization on top of the route policy: Admins and Global Admins may queue
/// every allowed type, Operators only the types in <see cref="OperatorAllowedTypes"/>.
/// Internal-static so tests can pin the matrix without an HTTP host.
/// </summary>
internal static bool IsTypeAllowedForCaller(string actionType, bool isTenantAdmin, bool isGlobalAdmin)
{
if (isTenantAdmin || isGlobalAdmin)
return true;
return OperatorAllowedTypes.Contains(actionType);
}
public QueueSessionActionFunction(
ILogger<QueueSessionActionFunction> logger,
ISessionRepository sessionRepo,
@@ -53,9 +75,11 @@ namespace AutopilotMonitor.Functions.Functions.Sessions
{
try
{
// Authentication + TenantAdminOrGA authorization enforced by PolicyEnforcementMiddleware.
// Authentication + TenantAdminOrOperator authorization enforced by
// PolicyEnforcementMiddleware; per-type re-gating for Operators happens below.
var tenantId = TenantHelper.GetTenantId(req);
var userIdentifier = TenantHelper.GetUserIdentifier(req);
var requestCtx = req.GetRequestContext();
string body;
using (var reader = new StreamReader(req.Body))
@@ -78,6 +102,20 @@ namespace AutopilotMonitor.Functions.Functions.Sessions
if (!AllowedTypes.Contains(action.Type))
return await BadRequestAsync(req, $"Unknown action type '{action.Type}'. Allowed: {string.Join(", ", AllowedTypes)}");
if (!IsTypeAllowedForCaller(action.Type, requestCtx.IsTenantAdmin, requestCtx.IsGlobalAdmin))
{
_logger.LogWarning(
"Operator {User} denied queueing admin-only action '{Type}' for session {SessionId}",
userIdentifier, action.Type, sessionId);
var forbidden = req.CreateResponse(HttpStatusCode.Forbidden);
await forbidden.WriteAsJsonAsync(new
{
success = false,
message = $"Action type '{action.Type}' requires the Tenant Admin role"
});
return forbidden;
}
// Stamp server-side fields — never trust client timestamps, and never let the caller
// forge a RuleId (that's reserved for the RuleEngine).
action.QueuedAt = DateTime.UtcNow;
@@ -336,6 +336,9 @@ public class PolicyEnforcementMiddleware : IFunctionsWorkerMiddleware
case EndpointPolicy.TenantAdminOrGlobalReader:
return await EvaluateTenantAdminOrGlobalReaderAsync(tenantId, upn, principal, userIdentifier);
case EndpointPolicy.TenantAdminOrOperator:
return await EvaluateTenantAdminOrOperatorAsync(tenantId, upn, principal, userIdentifier);
case EndpointPolicy.TenantAdminOrGA:
return await EvaluateTenantAdminOrGAAsync(tenantId, upn, principal, userIdentifier);
@@ -469,6 +472,28 @@ public class PolicyEnforcementMiddleware : IFunctionsWorkerMiddleware
return CatalogDecisionResult.Deny(userIdentifier, role?.Role ?? "NonMember", "NotAdminOrGA");
}
/// <summary>
/// Operational write tier: own-tenant Admin or Operator (no Viewer), or Global Admin.
/// The admitted member's role flows onto RequestContext.UserRole so a function that
/// serves multiple operation kinds can re-gate the admin-only ones on IsTenantAdmin.
/// The read-only Global Reader is deliberately NOT admitted — this is a write tier.
/// </summary>
private async Task<CatalogDecisionResult> EvaluateTenantAdminOrOperatorAsync(
string? tenantId, string? upn, ClaimsPrincipal? principal, string userIdentifier)
{
if (string.IsNullOrEmpty(tenantId) || string.IsNullOrEmpty(upn))
return CatalogDecisionResult.Deny(userIdentifier, "N/A", "MissingClaims");
if (await _globalAdminService.IsGlobalAdminAsync(upn))
return CatalogDecisionResult.Allow(userIdentifier, "GlobalAdmin", "GABypass");
var role = await ResolveEffectiveRoleAsync(tenantId, upn, principal);
if (role?.Role == Constants.TenantRoles.Admin || role?.Role == Constants.TenantRoles.Operator)
return CatalogDecisionResult.Allow(userIdentifier, role.Role, "TenantAdminOrOperator");
return CatalogDecisionResult.Deny(userIdentifier, role?.Role ?? "NonMember", "NotAdminOperatorOrGA");
}
private async Task<CatalogDecisionResult> EvaluateBootstrapManagerOrGAAsync(
string? tenantId, string? upn, ClaimsPrincipal? principal, string userIdentifier)
{
@@ -55,11 +55,22 @@ public enum EndpointPolicy
/// </summary>
MemberRead,
/// <summary>
/// Own-tenant Admin or Operator (no Viewer), OR Global Admin. Tenant-scoped OPERATIONAL write
/// access: actions a troubleshooting operator may take on a session/device without holding
/// config-level admin power. Routes on this tier whose function accepts multiple operation
/// kinds MUST re-gate the admin-only kinds in the function on
/// <c>RequestContext.IsTenantAdmin || IsGlobalAdmin</c> (see QueueSessionActionFunction).
/// </summary>
TenantAdminOrOperator,
/// <summary>
/// Own-tenant Admin, OR a platform role (Global Admin / Global Reader). Tenant-scoped admin-tier
/// READ access (e.g. GET config/{tenantId}). Like <see cref="TenantAdminOrGA"/> but additionally
/// admits the read-only Global Reader — used only on GET routes so the reader gets visibility
/// without write power, while tenant Operators/Viewers still cannot read admin-tier config.
/// READ access (e.g. GET tenants/{tenantId}/graph-permissions/status). Like
/// <see cref="TenantAdminOrGA"/> but additionally admits the read-only Global Reader — used only
/// on GET routes so the reader gets visibility without write power, while tenant
/// Operators/Viewers stay excluded. (GET config/{tenantId} moved OFF this tier to MemberRead:
/// its handler redacts secrets for every non-admin caller, so member-tier read is safe there.)
/// </summary>
TenantAdminOrGlobalReader,
@@ -233,12 +244,18 @@ public static class EndpointAccessPolicyCatalog
new("GET", "audit/logs", EndpointPolicy.MemberRead),
new("GET", "audit/hardware-rejected", EndpointPolicy.MemberRead),
new("GET", "audit/device-not-registered", EndpointPolicy.MemberRead),
new("GET", "audit/tpm-pss-unsupported", EndpointPolicy.MemberRead),
new("GET", "diagnostics/download-url", EndpointPolicy.MemberRead, TenantScoping.QueryParam),
new("POST", "diagnostics/download-ticket", EndpointPolicy.MemberRead, TenantScoping.QueryParam),
new("GET", "rules/gather", EndpointPolicy.MemberRead),
new("GET", "rules/analyze", EndpointPolicy.MemberRead),
new("GET", "rules/ime-log-patterns", EndpointPolicy.MemberRead),
new("GET", "config/{tenantId}", EndpointPolicy.TenantAdminOrGlobalReader, TenantScoping.RouteParam),
// Member tier so Operators/Viewers can see their tenant's configuration read-only in the
// Settings UI. Safe because the handler redacts by default: only a Global Admin or the
// tenant's OWN admin gets the unredacted secrets — every other admitted caller (Operator,
// Viewer, read-only Global Reader, delegated reader) receives RedactedCopyForReader()
// (see GetTenantConfigurationFunction.CanViewSecrets). Writes stay TenantAdminOrGA below.
new("GET", "config/{tenantId}", EndpointPolicy.MemberRead, TenantScoping.RouteParam),
new("GET", "config/{tenantId}/feature-flags", EndpointPolicy.MemberRead, TenantScoping.RouteParam),
// ── TenantAdminOrGA ─────────────────────────────────────────────
@@ -254,9 +271,15 @@ public static class EndpointAccessPolicyCatalog
new("PUT", "rules/ime-log-patterns/{patternId}", EndpointPolicy.TenantAdminOrGA),
new("POST", "sessions/{sessionId}/mark-failed", EndpointPolicy.TenantAdminOrGA),
new("POST", "sessions/{sessionId}/mark-succeeded", EndpointPolicy.TenantAdminOrGA),
new("POST", "sessions/{sessionId}/actions", EndpointPolicy.TenantAdminOrGA),
// Operator tier so troubleshooting staff can queue request_diagnostics ("Collect Logs" in
// the portal); the function re-gates the admin-only action types (terminate_session,
// rotate_config) on RequestContext.IsTenantAdmin || IsGlobalAdmin.
new("POST", "sessions/{sessionId}/actions", EndpointPolicy.TenantAdminOrOperator),
new("POST", "sessions/{sessionId}/report", EndpointPolicy.TenantAdminOrGA),
new("POST", "diagnostics/files", EndpointPolicy.TenantAdminOrGA),
// Operator tier: submitting diagnostic files to the Autopilot Monitor team is a
// troubleshooting action, not a config change — a support-driven Operator may use it.
// Viewer and the read-only Global Reader stay excluded (the tier admits no Viewer/Reader).
new("POST", "diagnostics/files", EndpointPolicy.TenantAdminOrOperator),
new("DELETE", "sessions/{sessionId}", EndpointPolicy.TenantAdminOrGA, TenantScoping.QueryParam),
new("GET", "tenants/{tenantId}/admins", EndpointPolicy.TenantAdminOrGA, TenantScoping.RouteParam),
new("POST", "tenants/{tenantId}/admins", EndpointPolicy.TenantAdminOrGA, TenantScoping.RouteParam),
@@ -388,6 +411,15 @@ public static class EndpointAccessPolicyCatalog
new("PATCH", "global/session-reports/{reportId}/note", EndpointPolicy.GlobalAdminOnly),
new("GET", "global/rules/gather", EndpointPolicy.GlobalReadOrAdmin, TenantScoping.QueryParam),
new("GET", "global/rules/analyze", EndpointPolicy.GlobalReadOrAdmin, TenantScoping.QueryParam),
// Cross-tenant rule WRITES (edit/toggle/delete an existing rule in a foreign tenant). GlobalAdminOnly
// — a write tier, so the delegated-read rescue never fires: only a platform admin may mutate another
// tenant's rules. QueryParam because the tenant is named by ?tenantId= (the {ruleId} segment is not a
// tenant). No global CREATE route: creating rules in a foreign tenant stays disabled (the portal hides
// Create in the cross-tenant override), so the JWT-scoped POST routes remain the only create path.
new("PUT", "global/rules/gather/{ruleId}", EndpointPolicy.GlobalAdminOnly, TenantScoping.QueryParam),
new("DELETE", "global/rules/gather/{ruleId}", EndpointPolicy.GlobalAdminOnly, TenantScoping.QueryParam),
new("PUT", "global/rules/analyze/{ruleId}", EndpointPolicy.GlobalAdminOnly, TenantScoping.QueryParam),
new("DELETE", "global/rules/analyze/{ruleId}", EndpointPolicy.GlobalAdminOnly, TenantScoping.QueryParam),
new("GET", "global/devices/blocked", EndpointPolicy.GlobalReadOrAdmin),
new("GET", "devices/blocked", EndpointPolicy.GlobalReadOrAdmin),
new("POST", "devices/block", EndpointPolicy.GlobalAdminOnly),
@@ -75,10 +75,23 @@ namespace AutopilotMonitor.Functions.Services.Diagnostics
if (destination == DestinationHosted)
{
// Prefix sweep first: the session may have produced several packages (on-demand
// server-requested uploads plus the terminal one) while the manifest carries only
// the last row-referenced name. Guarded by TryParse because a malformed legacy
// manifest must degrade to the single-blob delete, not poison the cascade.
var swept = 0;
if (Guid.TryParse(manifest.SessionId, out _))
{
swept = await _hostedDiagnostics.DeleteBySessionPrefixAsync(
manifest.TenantId, manifest.SessionId, cancellationToken);
}
// Belt-and-braces: the manifest-named blob covers any shape outside the canonical
// {tenantId}/AgentDiagnostics-{sessionId}- layout. Idempotent (404 = no-op).
await _hostedDiagnostics.DeleteIfExistsAsync(manifest.DiagnosticsBlobName, cancellationToken);
_logger.LogInformation(
"Cascade deleted hosted diagnostics blob tenant={TenantId} blob={Blob}",
manifest.TenantId, manifest.DiagnosticsBlobName);
"Cascade deleted hosted diagnostics blobs tenant={TenantId} blob={Blob} sweptByPrefix={Swept}",
manifest.TenantId, manifest.DiagnosticsBlobName, swept);
return DiagnosticsBlobDeleteOutcome.HostedDeleted;
}
@@ -165,6 +165,40 @@ namespace AutopilotMonitor.Functions.Services.Diagnostics
return response.Value;
}
/// <summary>
/// Deletes every hosted diagnostics blob belonging to one session
/// (prefix <c>{tenantId}/AgentDiagnostics-{sessionId}-</c>). A session can leave more
/// than one blob behind — an on-demand ("Collect Logs" / server-requested) upload is
/// later overwritten on the Sessions row by the terminal package, so deleting only the
/// row-referenced name would strand the earlier blobs at offboarding.
/// Returns the number of blobs deleted. Idempotent; a missing container is a no-op.
/// </summary>
public virtual async Task<int> DeleteBySessionPrefixAsync(
string tenantId, string sessionId, CancellationToken cancellationToken = default)
{
SecurityValidator.EnsureValidGuid(tenantId, nameof(tenantId));
SecurityValidator.EnsureValidGuid(sessionId, nameof(sessionId));
var containerClient = GetContainerClient();
if (!await containerClient.ExistsAsync(cancellationToken))
return 0;
// Trailing dash bounds the match to this exact session id (GUIDs are fixed-length,
// but the explicit separator keeps the invariant obvious and layout-proof).
var prefix = $"{tenantId}/AgentDiagnostics-{sessionId}-";
var deleted = 0;
await foreach (var item in containerClient.GetBlobsAsync(
BlobTraits.None, BlobStates.None, prefix: prefix, cancellationToken: cancellationToken))
{
var response = await containerClient.GetBlobClient(item.Name)
.DeleteIfExistsAsync(cancellationToken: cancellationToken);
if (response.Value)
deleted++;
}
return deleted;
}
/// <summary>
/// Enumerates blobs under a single tenant's prefix (<c>{tenantId}/</c>).
/// Used by ad-hoc operator scripts and (later) any orphan-scan job.
@@ -44,6 +44,19 @@ namespace AutopilotMonitor.Functions.Services
case "diagnostics_uploaded":
classification.DiagnosticsUploadedEvent = evt;
break;
case "server_action_executed":
// The on-demand ("Collect Logs") path confirms its upload via
// server_action_executed carrying blobName — the agent's dispatcher does
// NOT emit diagnostics_uploaded there (only the terminal path does).
// Treat it as the upload confirmation so the Sessions row gets stamped
// and the portal's Download button flips. Null-guard: a real
// diagnostics_uploaded in the same batch wins (it carries destination).
if (classification.DiagnosticsUploadedEvent == null
&& IsOnDemandDiagnosticsUploadConfirmation(evt))
{
classification.DiagnosticsUploadedEvent = evt;
}
break;
case "whiteglove_complete":
classification.WhiteGloveEvent = evt;
break;
@@ -116,6 +129,43 @@ namespace AutopilotMonitor.Functions.Services
/// reasons (decision_terminal, ctrl_c, process_exit, unhandled_exception, ...) either
/// follow a real terminal event or imply nothing terminal.
/// </summary>
/// <summary>
/// True for a <c>server_action_executed</c> event that confirms an on-demand
/// ("Collect Logs") diagnostics upload: <c>actionType=request_diagnostics</c> with a
/// non-empty <c>blobName</c>. The agent's ServerActionDispatcher emits no
/// <c>diagnostics_uploaded</c> on that path — this event IS the upload confirmation
/// and must stamp the Sessions row (session 8e4cc4ae: two successful uploads landed
/// in hosted storage but the portal never flipped because the row stayed empty).
/// </summary>
internal static bool IsOnDemandDiagnosticsUploadConfirmation(EnrollmentEvent? evt)
{
if (evt?.Data == null || !string.Equals(evt.EventType, "server_action_executed", StringComparison.Ordinal))
return false;
var actionType = evt.Data.ContainsKey("actionType") ? evt.Data["actionType"]?.ToString() : null;
if (!string.Equals(actionType, "request_diagnostics", StringComparison.OrdinalIgnoreCase))
return false;
var blobName = evt.Data.ContainsKey("blobName") ? evt.Data["blobName"]?.ToString() : null;
return !string.IsNullOrEmpty(blobName);
}
/// <summary>
/// Destination fallback for upload confirmations that carry no <c>destination</c>
/// (the on-demand server_action_executed path, and legacy agents). Only the Hosted
/// path persists the backend-built <c>{tenantId}/{filename}</c> shape
/// (HostedDiagnosticsBlobService.BuildBlobPath); CustomerSas blobs are bare filenames.
/// Returns the explicit destination when present, "Hosted" for a tenant-prefixed blob
/// name, otherwise null (repo leaves the column unchanged; read-time default is
/// CustomerSas).
/// </summary>
internal static string? InferDiagnosticsDestination(string? destination, string? blobName, string tenantId)
{
if (!string.IsNullOrEmpty(destination))
return destination;
if (blobName?.StartsWith(tenantId + "/", StringComparison.OrdinalIgnoreCase) == true)
return "Hosted";
return null;
}
internal static bool IsMaxLifetimeAgentShutdown(EnrollmentEvent? evt)
{
if (evt == null || !string.Equals(evt.EventType, "agent_shutting_down", StringComparison.Ordinal))
@@ -300,6 +300,10 @@ namespace AutopilotMonitor.Functions.Services
var destination = data?.ContainsKey("destination") == true
? data["destination"]?.ToString()
: null;
// server_action_executed (on-demand collection) carries no destination at all —
// infer Hosted from the tenant-prefixed blob-name shape. Without this the
// download route would look in the customer's container for a hosted blob.
destination = InferDiagnosticsDestination(destination, blobName, request.TenantId);
if (!string.IsNullOrEmpty(blobName))
{
await _sessionRepo.UpdateSessionDiagnosticsBlobAsync(
@@ -26,6 +26,7 @@ public static class TenantNotificationAudienceCatalog
private static readonly Dictionary<string, NotificationAudience> _map = new(System.StringComparer.OrdinalIgnoreCase)
{
["hardware_rejection"] = NotificationAudience.Admin,
["tpm_pss_unsupported"] = NotificationAudience.Admin,
["sla_breach"] = NotificationAudience.Member,
["sla_consecutive_failures"] = NotificationAudience.Member,
["sla_resolved"] = NotificationAudience.Member,
@@ -4,23 +4,37 @@ using System.Threading.Tasks;
namespace AutopilotMonitor.Shared.DataAccess
{
/// <summary>
/// Tracks which (tenant, manufacturer, model) combinations have already triggered a bell
/// notification. Used by ReportDistressFunction to fire a tenant-admin notification at most
/// once per model per tenant (lifetime dedup).
/// First-notification dedup store for distress-driven bell notifications. Used by
/// ReportDistressFunction to fire each tenant-admin notification at most once per subject
/// (lifetime dedup): hardware rejections per (tenant, manufacturer, model) and TPM PSS
/// incompatibilities per (tenant, serial number).
/// </summary>
public interface IHardwareRejectionNotificationTracker
{
/// <summary>
/// Atomically records a first-time notification for the given (tenantId, manufacturer, model).
/// Atomically records a first-time hardware-rejection notification for the given
/// (tenantId, manufacturer, model).
/// Returns true if this is the first time (caller should fire the notification),
/// false if an entry already exists (caller should skip).
/// Manufacturer and model are matched case-insensitively after trim.
/// </summary>
Task<bool> TryRegisterFirstNotificationAsync(string tenantId, string manufacturer, string model);
/// <summary>
/// Atomically records a first-time TPM-PSS-unsupported notification for the given
/// (tenantId, serialNumber). Same semantics as
/// <see cref="TryRegisterFirstNotificationAsync"/>; the serial number is matched
/// case-insensitively after trim.
/// </summary>
Task<bool> TryRegisterFirstTpmPssNotificationAsync(string tenantId, string serialNumber);
/// <summary>
/// Retention cleanup: deletes tracker rows whose FirstNotifiedAt is older than
/// <paramref name="cutoffUtc"/>. Pruning re-arms the dedup for that model. Returns rows deleted.
/// <paramref name="cutoffUtc"/>. Both key spaces live in one table, so pruning re-arms the
/// dedup for BOTH subjects: a hardware model rejected again after the cutoff rings once more,
/// and so does a TPM-PSS-incompatible device that reports again. That is intended — a bell
/// that can never ring a second time would go silent on a device the tenant never fixed.
/// Returns rows deleted.
/// </summary>
Task<int> DeleteOlderThanAsync(DateTime cutoffUtc);
}
@@ -27,9 +27,9 @@ const FEATURES = [
{
title: "Diagnostics Collection",
description:
"Collect agent logs, IME logs, agent state, and device information as a ZIP bundle at the end of an enrollment — then download it from the session view without touching the device.",
"Collect agent logs, IME logs, agent state, and device information as a ZIP bundle — automatically at the end of an enrollment, or on demand while one is still running — then download it from the session view without touching the device.",
color: "green",
bullets: ["Configurable upload: off, always, or on failure", "Agent, IME & device information bundle", "Configurable additional log paths"],
bullets: ["Configurable upload: off, always, or on failure", "On-demand collection from a running enrollment", "Agent, IME & device information bundle", "Configurable additional log paths"],
},
{
title: "Detailed Event Timeline",
@@ -80,7 +80,9 @@ export default function AnalyzeRuleCard({
>
{/* Collapsed Header */}
<div className="p-4 cursor-pointer select-none" onClick={() => { if (isEditing) return; onToggle(); }}>
<div className="flex items-center space-x-4">
{/* Mobile: badges wrap and the title drops onto its own full-width row (order-last);
≥sm: single line with the title in the middle, exactly as before. */}
<div className="flex flex-wrap items-center gap-x-4 gap-y-2">
{isTemplateVariant ? (
<span className="inline-flex h-6 w-6 flex-shrink-0 items-center justify-center rounded bg-amber-100 text-amber-600" title="Template — enabling creates a custom rule copy">
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 7H5a2 2 0 00-2 2v9a2 2 0 002 2h9a2 2 0 002-2v-3m-9-4h6m-6 4h6m2-11l4 4m0 0l-4 4m4-4H9" /></svg>
@@ -127,8 +129,8 @@ export default function AnalyzeRuleCard({
{rule.severity.charAt(0).toUpperCase() + rule.severity.slice(1)}
</span>
<span className="text-xs font-mono text-gray-400 flex-shrink-0 hidden sm:inline">{rule.ruleId}</span>
<div className="flex-1 min-w-0">
<h3 className="text-sm font-semibold text-gray-900 truncate">{rule.title}</h3>
<div className="order-last w-full min-w-0 sm:order-none sm:w-auto sm:flex-1">
<h3 className="text-sm font-semibold text-gray-900 sm:truncate">{rule.title}</h3>
</div>
<span className={`inline-flex items-center px-2 py-0.5 rounded text-xs font-medium ${catColor.bg} ${catColor.text} flex-shrink-0`}>
{rule.category.charAt(0).toUpperCase() + rule.category.slice(1)}
@@ -159,7 +161,7 @@ export default function AnalyzeRuleCard({
{hitRate}% hit rate
</span>
)}
<svg className={`w-5 h-5 text-gray-400 transition-transform flex-shrink-0 ${isExpanded ? "rotate-180" : ""}`} fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" /></svg>
<svg className={`w-5 h-5 text-gray-400 transition-transform flex-shrink-0 ml-auto sm:ml-0 ${isExpanded ? "rotate-180" : ""}`} fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" /></svg>
</div>
</div>
@@ -89,6 +89,14 @@ export default function AnalyzeRulesPage() {
// is read-only. Backend also enforces (rules write is TenantAdminOrGA, cross-tenant blocked for non-GA).
const isReadOnly = !(user?.isGlobalAdmin || (user?.isTenantAdmin && !isGlobalOverride));
// Cross-tenant write routing: in a Global Admin override the JWT-scoped rules/analyze/{id} route
// resolves the tenant from the caller's token and would silently upsert into the GA's OWN tenant
// (200 → false "saved"), so edit/toggle/notify/delete MUST target the global route with ?tenantId=.
const analyzeRuleWriteUrl = (ruleId: string) =>
isGlobalOverride
? api.rules.globalAnalyzeRule(ruleId, effectiveTenantId)
: api.rules.analyzeRule(ruleId);
const fetchRules = useCallback(async () => {
if (!effectiveTenantId) return;
const url = isGlobalOverride
@@ -173,15 +181,22 @@ export default function AnalyzeRulesPage() {
// Toggle rule enabled/disabled
const handleToggleRule = async (rule: AnalyzeRule) => {
// Intercept: if this is a template rule being enabled, open the config modal instead
// Intercept: if this is a template rule being enabled, open the config modal instead.
// Enabling a template CREATES a new custom rule — a create, which is disabled in the
// cross-tenant override (like the hidden Create button), so it would misroute to the GA's
// own tenant. Block it with a clear message rather than silently writing to the wrong tenant.
if ((rule.templateVariables?.length ?? 0) > 0 && !rule.enabled) {
if (isGlobalOverride) {
showError("Creating a rule from a template isn't available while viewing another tenant. Switch to that tenant to add rules.");
return;
}
setConfigureTemplateRule(rule);
return;
}
setTogglingRuleId(rule.ruleId);
const result = await mutate(
api.rules.analyzeRule(rule.ruleId),
analyzeRuleWriteUrl(rule.ruleId),
{
method: "PUT",
headers: { "Content-Type": "application/json" },
@@ -211,7 +226,7 @@ export default function AnalyzeRulesPage() {
setTogglingRuleId(rule.ruleId);
const result = await mutate(
api.rules.analyzeRule(rule.ruleId),
analyzeRuleWriteUrl(rule.ruleId),
{
method: "PUT",
headers: { "Content-Type": "application/json" },
@@ -235,7 +250,7 @@ export default function AnalyzeRulesPage() {
setTogglingRuleId(rule.ruleId);
const result = await mutate(
api.rules.analyzeRule(rule.ruleId),
analyzeRuleWriteUrl(rule.ruleId),
{
method: "PUT",
headers: { "Content-Type": "application/json" },
@@ -263,7 +278,7 @@ export default function AnalyzeRulesPage() {
setDeletingRuleId(rule.ruleId);
const result = await mutate(
api.rules.analyzeRule(rule.ruleId),
analyzeRuleWriteUrl(rule.ruleId),
{ method: "DELETE" }
);
if (result !== null) {
@@ -359,7 +374,7 @@ export default function AnalyzeRulesPage() {
};
const result = await mutate(
api.rules.analyzeRule(rule.ruleId),
analyzeRuleWriteUrl(rule.ruleId),
{
method: "PUT",
headers: { "Content-Type": "application/json" },
@@ -82,7 +82,9 @@ export function GatherRuleCard({
onExpand();
}}
>
<div className="flex items-center space-x-4">
{/* Mobile: badges wrap and the title drops onto its own full-width row (order-last);
≥sm: single line with the title in the middle, exactly as before. */}
<div className="flex flex-wrap items-center gap-x-4 gap-y-2">
{/* Enable/Disable Toggle */}
{readOnly ? (
<span
@@ -119,8 +121,8 @@ export function GatherRuleCard({
</span>
{/* Title */}
<div className="flex-1 min-w-0">
<h3 className="text-sm font-semibold text-gray-900 truncate">
<div className="order-last w-full min-w-0 sm:order-none sm:w-auto sm:flex-1">
<h3 className="text-sm font-semibold text-gray-900 sm:truncate">
{rule.title}
</h3>
</div>
@@ -150,7 +152,7 @@ export function GatherRuleCard({
{/* Expand/Collapse Arrow */}
<svg
className={`w-5 h-5 text-gray-400 transition-transform flex-shrink-0 ${
className={`w-5 h-5 text-gray-400 transition-transform flex-shrink-0 ml-auto sm:ml-0 ${
isExpanded ? "rotate-180" : ""
}`}
fill="none"
@@ -75,6 +75,14 @@ export default function GatherRulesPage() {
// is read-only. Backend also enforces (rules write is TenantAdminOrGA, cross-tenant blocked for non-GA).
const isReadOnly = !(user?.isGlobalAdmin || (user?.isTenantAdmin && !isGlobalOverride));
// Cross-tenant write routing: in a Global Admin override the JWT-scoped rules/gather/{id} route
// resolves the tenant from the caller's token and would silently upsert into the GA's OWN tenant
// (200 → false "saved"), so edit/toggle/delete MUST target the global route that carries ?tenantId=.
const gatherRuleWriteUrl = (ruleId: string) =>
isGlobalOverride
? api.rules.globalGatherRule(ruleId, effectiveTenantId)
: api.rules.gatherRule(ruleId, effectiveTenantId);
const fetchRules = useCallback(async () => {
if (!effectiveTenantId) return;
const url = isGlobalOverride
@@ -119,7 +127,7 @@ export default function GatherRulesPage() {
const handleToggleRule = async (rule: GatherRule) => {
setTogglingRule(rule.ruleId);
const result = await mutate(
api.rules.gatherRule(rule.ruleId, effectiveTenantId),
gatherRuleWriteUrl(rule.ruleId),
{
method: "PUT",
headers: { "Content-Type": "application/json" },
@@ -142,7 +150,7 @@ export default function GatherRulesPage() {
setDeletingRule(rule.ruleId);
const result = await mutate(
api.rules.gatherRule(rule.ruleId, effectiveTenantId),
gatherRuleWriteUrl(rule.ruleId),
{ method: "DELETE" }
);
if (result !== null) {
@@ -323,7 +331,7 @@ export default function GatherRulesPage() {
};
const result = await mutate(
api.rules.gatherRule(rule.ruleId, effectiveTenantId),
gatherRuleWriteUrl(rule.ruleId),
{
method: "PUT",
headers: { "Content-Type": "application/json" },
@@ -124,6 +124,12 @@ body {
.dark .hover\:bg-gray-100:hover {
background-color: #334155 !important;
}
.dark .hover\:bg-gray-200:hover {
background-color: #334155 !important;
}
.dark .hover\:bg-gray-300:hover {
background-color: #475569 !important;
}
.dark .hover\:bg-indigo-100:hover {
background-color: #312e81 !important;
}
@@ -133,6 +139,60 @@ body {
.dark .hover\:bg-cyan-50:hover {
background-color: #0c2a3d !important;
}
/* Light hover tints (50/100/200) flash near-white in dark mode without an
override — map each family onto its dark -50 tone (one step lighter for
the 100/200 variants so hovering a -50 base still gives feedback). */
.dark .hover\:bg-blue-50:hover {
background-color: #1e3a5f !important;
}
.dark .hover\:bg-blue-100:hover,
.dark .hover\:bg-blue-200:hover {
background-color: #26497a !important;
}
.dark .hover\:bg-purple-50:hover {
background-color: #2d1b4e !important;
}
.dark .hover\:bg-purple-100:hover,
.dark .hover\:bg-purple-200:hover {
background-color: #3b2565 !important;
}
.dark .hover\:bg-red-50:hover {
background-color: #3b1c1c !important;
}
.dark .hover\:bg-red-100:hover,
.dark .hover\:bg-red-200:hover {
background-color: #4a2424 !important;
}
.dark .hover\:bg-amber-50:hover,
.dark .hover\:bg-yellow-50:hover {
background-color: #3d2e0a !important;
}
.dark .hover\:bg-amber-100:hover,
.dark .hover\:bg-amber-200:hover,
.dark .hover\:bg-yellow-100:hover {
background-color: #4d3a0e !important;
}
.dark .hover\:bg-green-100:hover,
.dark .hover\:bg-green-200:hover {
background-color: #1a4436 !important;
}
.dark .hover\:bg-orange-100:hover,
.dark .hover\:bg-orange-200:hover {
background-color: #4d300d !important;
}
.dark .hover\:bg-sky-100:hover {
background-color: #10394f !important;
}
.dark .hover\:bg-teal-50:hover {
background-color: #0d2e2e !important;
}
.dark .hover\:bg-emerald-100:hover {
background-color: #124430 !important;
}
.dark .hover\:bg-slate-50:hover,
.dark .hover\:bg-slate-100:hover {
background-color: #334155 !important;
}
/* -- Input fields -- */
.dark input,
@@ -233,6 +293,35 @@ body {
background-color: #3d2e0a !important;
}
/* -- Slate family (neutral chips like the "Incomplete" status filter) — mirrors gray -- */
.dark .bg-slate-50 {
background-color: #0f172a !important;
}
.dark .bg-slate-100 {
background-color: #1e293b !important;
}
.dark .text-slate-900 {
color: #f1f5f9 !important;
}
.dark .text-slate-700 {
color: #cbd5e1 !important;
}
.dark .text-slate-600 {
color: #94a3b8 !important;
}
.dark .text-slate-500 {
color: #64748b !important;
}
.dark .text-slate-400 {
color: #64748b !important;
}
.dark .border-slate-200 {
border-color: #334155 !important;
}
.dark .border-slate-300 {
border-color: #475569 !important;
}
/* -- Gradient backgrounds (card headers) -- */
.dark [class*="from-purple-50"],
.dark [class*="from-indigo-50"],
@@ -60,7 +60,9 @@ export default function ImePatternCard({
onToggle();
}}
>
<div className="flex items-center space-x-4">
{/* Mobile: badges wrap and the title drops onto its own full-width row (order-last);
≥sm: single line with the title in the middle, exactly as before. */}
<div className="flex flex-wrap items-center gap-x-4 gap-y-2">
{/* Enable/Disable Toggle */}
{canEdit ? (
<button
@@ -101,8 +103,8 @@ export default function ImePatternCard({
{pattern.patternId}
</span>
<div className="flex-1 min-w-0">
<h3 className="text-sm font-semibold text-gray-900 truncate">
<div className="order-last w-full min-w-0 sm:order-none sm:w-auto sm:flex-1">
<h3 className="text-sm font-semibold text-gray-900 sm:truncate">
{pattern.description || pattern.patternId}
</h3>
</div>
@@ -120,7 +122,7 @@ export default function ImePatternCard({
</span>
<svg
className={`w-5 h-5 text-gray-400 transition-transform flex-shrink-0 ${
className={`w-5 h-5 text-gray-400 transition-transform flex-shrink-0 ml-auto sm:ml-0 ${
isExpanded ? "rotate-180" : ""
}`}
fill="none"
@@ -148,8 +148,17 @@ export default function PrivacyPage() {
<p className="text-gray-700">
Diagnostics upload is <strong>off by default</strong>. When enabled, the default destination is{" "}
<strong>your own Azure storage account</strong> — the package never reaches our infrastructure. Hosted upload
exists as an alternative but is opt-in only and requires an explicit administrator action behind a clearly
marked disclosure that data leaves your tenant. It is never enabled silently.
exists as an alternative but is opt-in only and requires an explicit administrator action — in Settings, or
in the one-step dialog offered on a session — always behind a clearly marked disclosure that data leaves your
tenant. It is never enabled silently.
</p>
<p className="text-gray-700">
Once upload is configured, an <strong>Admin or Operator in your own tenant</strong> can also request a
package on demand from an enrollment that is still running, instead of waiting for it to finish. It collects
exactly what the automatic package collects — the configured paths, nothing wider — the device only acts on
the request when the agent next checks in, and both the request and its outcome appear as events on that
session&apos;s timeline. Viewers and Progress-Portal members cannot trigger it, and it does nothing on a
tenant that has no upload destination configured.
</p>
<h2 className="text-xl font-semibold text-gray-900 mt-6">How Long We Keep Data</h2>
@@ -0,0 +1,320 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import { EnrollmentEvent } from "@/types";
import { api } from "@/lib/api";
import { authenticatedFetch, TokenExpiredError } from "@/lib/authenticatedFetch";
import { NotificationType } from "@/contexts/NotificationContext";
import {
CollectPhase,
COLLECT_TIMEOUT_MS,
evaluateCollectProgress,
resolveCollectButtonState,
} from "./collectLogsLogic";
interface CollectLogsButtonProps {
sessionId: string;
effectiveTenantId?: string;
sessionStatus?: string;
/** Live from the session object (SignalR sessionUpdate delta) — a change means the package landed. */
diagnosticsBlobName?: string;
isCrossTenantView: boolean;
isTenantAdmin: boolean;
isOperator: boolean;
diagnosticsConfigured: boolean;
/** Called after a successful quick-config so the page-level flag flips without a refetch. */
onDiagnosticsConfigured: () => void;
events: EnrollmentEvent[];
getAccessToken: (forceRefresh?: boolean) => Promise<string | null>;
addNotification: (type: NotificationType, title: string, message: string, key?: string) => void;
}
/**
* "Collect Logs" — queues a `request_diagnostics` ServerAction that the agent picks up with
* its next ingest response, then tracks progress via the live event stream
* (server_action_received/_executed/_failed) and the DiagnosticsBlobName session delta.
*
* Always rendered, even when disabled: the visible-but-disabled button (with a tooltip
* explaining what is missing) is the deliberate nudge towards configuring diagnostics upload.
* Tenant Admins on an unconfigured tenant get the quick-config dialog instead (enable
* Hosted + OnFailure, then collect immediately) — that dialog IS the explicit opt-in.
*/
export default function CollectLogsButton({
sessionId,
effectiveTenantId,
sessionStatus,
diagnosticsBlobName,
isCrossTenantView,
isTenantAdmin,
isOperator,
diagnosticsConfigured,
onDiagnosticsConfigured,
events,
getAccessToken,
addNotification,
}: CollectLogsButtonProps) {
const [phase, setPhase] = useState<CollectPhase>("idle");
const [showQuickConfig, setShowQuickConfig] = useState(false);
const [quickConfigBusy, setQuickConfigBusy] = useState(false);
const baselineSequenceRef = useRef(0);
const baselineBlobRef = useRef<string | undefined>(undefined);
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const buttonState = resolveCollectButtonState({
sessionStatus,
isCrossTenantView,
isTenantAdmin,
isOperator,
diagnosticsConfigured,
phase,
});
const notifyError = useCallback((err: unknown, fallback: string) => {
if (err instanceof TokenExpiredError) {
addNotification("error", "Session Expired", err.message, "session-expired-error");
} else {
addNotification("error", "Collect Logs", fallback, "collect-logs");
}
}, [addNotification]);
const queueAction = useCallback(async (type: string, reason: string): Promise<boolean> => {
const res = await authenticatedFetch(
api.sessions.queueAction(sessionId, effectiveTenantId),
getAccessToken,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ type, reason }),
},
);
return res.ok;
}, [sessionId, effectiveTenantId, getAccessToken]);
const startCollect = useCallback(async () => {
setPhase("working");
// Sequence baseline: only server_action events NEWER than the click count as feedback
// for THIS request (at-least-once redelivery of older actions must not match).
baselineSequenceRef.current = events.reduce((max, e) => Math.max(max, e.sequence ?? 0), 0);
baselineBlobRef.current = diagnosticsBlobName;
try {
const ok = await queueAction("request_diagnostics", "On-demand log collection from portal");
if (!ok) {
setPhase("idle");
addNotification("error", "Collect Logs", "Failed to queue the log collection request.", "collect-logs");
return;
}
setPhase("queued");
addNotification("info", "Collect Logs",
"Request queued — the agent picks it up with its next check-in and uploads a diagnostics package.",
"collect-logs");
} catch (err) {
setPhase("idle");
notifyError(err, "Failed to queue the log collection request.");
}
}, [events, diagnosticsBlobName, queueAction, addNotification, notifyError]);
const handleClick = () => {
if (!buttonState.enabled) return;
if (buttonState.opensQuickConfig) {
setShowQuickConfig(true);
} else {
void startCollect();
}
};
// Quick-config (Admin only): read the full tenant config, flip ONLY the two diagnostics
// fields, write it back verbatim, then rotate_config BEFORE request_diagnostics so the
// agent refetches the now-enabled config before building the package.
const handleQuickConfig = async () => {
if (!effectiveTenantId) return;
setQuickConfigBusy(true);
try {
const getRes = await authenticatedFetch(api.config.tenant(effectiveTenantId), getAccessToken);
if (!getRes.ok) throw new Error(`Failed to load tenant configuration (${getRes.status})`);
const config = await getRes.json();
const updated = {
...config,
diagnosticsUploadDestination: "Hosted",
// OnFailure is the least invasive mode that unlocks on-demand collection (the
// server-requested path always uploads); keep a stricter existing mode untouched.
diagnosticsUploadMode:
!config.diagnosticsUploadMode || config.diagnosticsUploadMode === "Off"
? "OnFailure"
: config.diagnosticsUploadMode,
};
const putRes = await authenticatedFetch(api.config.tenant(effectiveTenantId), getAccessToken, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(updated),
});
if (!putRes.ok) throw new Error(`Failed to save tenant configuration (${putRes.status})`);
onDiagnosticsConfigured();
setShowQuickConfig(false);
// Order matters: the agent processes queued actions sequentially, so rotate_config
// must be first for the collection to run against the new config.
const rotated = await queueAction("rotate_config", "Enable diagnostics upload (quick config from portal)");
if (!rotated) {
addNotification("warning", "Collect Logs",
"Configuration saved, but queueing the config refresh failed — try Collect Logs again.",
"collect-logs");
return;
}
await startCollect();
} catch (err) {
notifyError(err, err instanceof Error ? err.message : "Quick configuration failed.");
} finally {
setQuickConfigBusy(false);
}
};
// Progress watcher: SignalR keeps `events` and the session delta fresh; sequence-based
// matching (see collectLogsLogic) makes this immune to agent clock skew.
useEffect(() => {
const result = evaluateCollectProgress({
phase,
baselineSequence: baselineSequenceRef.current,
baselineBlobName: baselineBlobRef.current,
currentBlobName: diagnosticsBlobName,
events,
});
if (result.kind === "done") {
setPhase("idle");
addNotification("success", "Collect Logs",
"Diagnostics package uploaded — it is now available via Download Diagnostics.",
"collect-logs");
} else if (result.kind === "failed") {
setPhase("idle");
addNotification("error", "Collect Logs", `The agent could not upload the package: ${result.reason}`, "collect-logs");
} else if (result.kind === "collecting" && phase === "queued") {
setPhase("collecting");
}
}, [events, diagnosticsBlobName, phase, addNotification]);
// Give up after a quiet timeout — the agent may have exited. The package still appears
// via SignalR if it reconnects later; the button just returns to idle for a retry.
useEffect(() => {
if (phase === "queued" || phase === "collecting") {
if (!timeoutRef.current) {
timeoutRef.current = setTimeout(() => {
timeoutRef.current = null;
setPhase("idle");
addNotification("warning", "Collect Logs",
"No response from the agent yet — it may no longer be running. The package will still appear if the agent checks in later.",
"collect-logs");
}, COLLECT_TIMEOUT_MS);
}
} else if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
timeoutRef.current = null;
}
return () => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
timeoutRef.current = null;
}
};
}, [phase, addNotification]);
return (
<>
<button
onClick={handleClick}
disabled={!buttonState.enabled}
title={buttonState.tooltip}
className={`px-4 py-2 rounded-md transition-colors flex items-center gap-2 text-sm ${
buttonState.enabled
? "bg-sky-600 text-white hover:bg-sky-700"
: "bg-white border border-gray-200 text-gray-400 cursor-not-allowed"
}`}
>
{phase !== "idle" ? (
<svg className="w-4 h-4 animate-spin" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z" />
</svg>
) : (
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
d="M9 17v-2a2 2 0 012-2h2a2 2 0 012 2v2m-8 4h10a2 2 0 002-2V9a2 2 0 00-2-2h-3.586a1 1 0 01-.707-.293l-1.414-1.414A1 1 0 0012.586 5H7a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
)}
{buttonState.busyLabel ?? "Collect Logs"}
</button>
{showQuickConfig && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50" onClick={() => !quickConfigBusy && setShowQuickConfig(false)}>
<div className="bg-white rounded-lg shadow-xl max-w-md w-full mx-4" onClick={(e) => e.stopPropagation()}>
<div className="p-6">
<div className="flex items-center mb-4">
<div className="flex-shrink-0 w-12 h-12 bg-sky-100 rounded-full flex items-center justify-center">
<svg className="w-6 h-6 text-sky-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
</div>
<h3 className="ml-4 text-lg font-semibold text-gray-900">Enable Diagnostics Upload</h3>
</div>
<div className="mb-6 space-y-2">
<p className="text-sm text-gray-700">
Diagnostics upload is not configured for this tenant, so the agent has nowhere to send the logs.
</p>
<p className="text-sm text-gray-700">
<span className="font-semibold">Enable &amp; Collect</span> switches the upload destination to
{" "}<span className="font-semibold">hosted storage</span> (managed by AutopilotMonitor) with mode
{" "}<span className="font-semibold">On failure</span>, then collects the logs from this device right away.
</p>
{/* Data-boundary disclosure. This dialog is the SECOND opt-in path into hosted
upload (the first is Settings → Diagnostics Package), and the privacy page and
trust FAQ both promise that hosted upload is only ever enabled behind a clearly
marked "data leaves your tenant" disclosure — this block is that promise here. */}
<div className="bg-sky-50 border border-sky-200 rounded-lg p-3 space-y-1">
<p className="text-sm text-sky-900">
<span className="font-semibold">Uploaded packages leave your own Azure tenant boundary.</span>{" "}
They are stored in the AutopilotMonitor backend&apos;s Azure Storage (Germany West Central),
isolated per tenant, and each upload uses a fresh blob-scoped, write-only token.
</p>
<p className="text-xs text-sky-800">
Packages are removed automatically according to your <span className="font-semibold">Data Retention Days</span> setting.
</p>
</div>
<p className="text-sm text-gray-600">
You can change the destination (e.g. to your own Azure Blob Storage) or turn uploads off again at any
time under Settings → Diagnostics Package, which also lists exactly which paths are collected.
</p>
</div>
<div className="flex justify-end gap-3">
<button
onClick={() => setShowQuickConfig(false)}
disabled={quickConfigBusy}
className="px-4 py-2 bg-gray-200 text-gray-700 rounded-md hover:bg-gray-300 transition-colors disabled:opacity-50"
>
Cancel
</button>
<button
onClick={() => void handleQuickConfig()}
disabled={quickConfigBusy}
className="px-4 py-2 bg-sky-600 text-white rounded-md hover:bg-sky-700 transition-colors disabled:opacity-50 flex items-center gap-2"
>
{quickConfigBusy && (
<svg className="w-4 h-4 animate-spin" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z" />
</svg>
)}
Enable &amp; Collect
</button>
</div>
</div>
</div>
</div>
)}
</>
);
}
@@ -390,7 +390,9 @@ export default function PhaseTimeline({ currentPhase, completedPhases, events =
{status === 'completed' ? '✓' : status === 'failed' ? '✕' : status === 'skipped' ? '⊘' : phase.id + 1}
</div>
<div className="mt-3 text-center">
<div className={`text-xs font-medium whitespace-nowrap ${status === 'skipped' ? 'text-gray-400' : 'text-gray-700'}`}>
{/* No whitespace-nowrap: long names ("Device Preparation") wrap inside their own
column instead of overlapping the neighbours when columns get narrow. */}
<div className={`text-xs font-medium break-words px-0.5 ${status === 'skipped' ? 'text-gray-400' : 'text-gray-700'}`}>
{phase.shortName}
</div>
{(status === 'completed' || status === 'failed') && getPhaseDuration(phase.id) && (
@@ -420,8 +422,14 @@ export default function PhaseTimeline({ currentPhase, completedPhases, events =
);
};
// Mobile: below ~80px per phase the 48px circles and their labels physically overlap,
// so give the timeline a minimum width and let it scroll horizontally instead of
// squeezing. On desktop the container is wider than the minimum → no scrollbar.
const minTimelineWidth = phases.length * 80 + (isWhiteGloveV1 && splitIndex > 0 ? 32 : 0);
return (
<div className="w-full py-4">
<div className="w-full py-4 overflow-x-auto">
<div style={{ minWidth: `${minTimelineWidth}px` }}>
{/* Group labels for WhiteGlove sessions */}
{isWhiteGloveV1 && splitIndex > 0 && (
<div className="flex w-full mb-3">
@@ -454,6 +462,7 @@ export default function PhaseTimeline({ currentPhase, completedPhases, events =
);
})}
</div>
</div>
</div>
);
}
@@ -0,0 +1,183 @@
import { describe, it, expect } from "vitest";
import {
evaluateCollectProgress,
resolveCollectButtonState,
ACTIVE_SESSION_STATUSES,
CollectWatchEvent,
} from "../collectLogsLogic";
// The Collect Logs button is ALWAYS rendered; these tests pin which combination of
// session state / role / tenant configuration enables it, when a click opens the
// quick-config dialog instead, and how live events + the DiagnosticsBlobName delta
// drive the progress state. Pure logic — no DOM.
const baseInput = {
sessionStatus: "InProgress",
isCrossTenantView: false,
isTenantAdmin: false,
isOperator: false,
diagnosticsConfigured: true,
phase: "idle" as const,
};
describe("resolveCollectButtonState", () => {
it("enables direct collect for an Operator when diagnostics is configured", () => {
const s = resolveCollectButtonState({ ...baseInput, isOperator: true });
expect(s.enabled).toBe(true);
expect(s.opensQuickConfig).toBe(false);
});
it("enables direct collect for a Tenant Admin when diagnostics is configured", () => {
const s = resolveCollectButtonState({ ...baseInput, isTenantAdmin: true });
expect(s.enabled).toBe(true);
expect(s.opensQuickConfig).toBe(false);
});
it("routes an Admin on an UNCONFIGURED tenant into the quick-config dialog", () => {
const s = resolveCollectButtonState({
...baseInput, isTenantAdmin: true, diagnosticsConfigured: false,
});
expect(s.enabled).toBe(true);
expect(s.opensQuickConfig).toBe(true);
});
it("disables (with the nudge tooltip) for an Operator on an unconfigured tenant", () => {
const s = resolveCollectButtonState({
...baseInput, isOperator: true, diagnosticsConfigured: false,
});
expect(s.enabled).toBe(false);
expect(s.tooltip).toMatch(/Tenant Admin can enable/);
});
it("disables for a Viewer even when configured", () => {
const s = resolveCollectButtonState({ ...baseInput });
expect(s.enabled).toBe(false);
expect(s.tooltip).toMatch(/Operator or Admin/);
});
it.each(["Succeeded", "Failed", "Incomplete", undefined, null])(
"disables for non-active session status %s (agent gone)",
(status) => {
const s = resolveCollectButtonState({
...baseInput, isTenantAdmin: true, sessionStatus: status as string | null | undefined,
});
expect(s.enabled).toBe(false);
expect(s.tooltip).toMatch(/no longer active/);
},
);
it.each(ACTIVE_SESSION_STATUSES)("treats %s as an active status", (status) => {
const s = resolveCollectButtonState({ ...baseInput, isTenantAdmin: true, sessionStatus: status });
expect(s.enabled).toBe(true);
});
it("disables in cross-tenant views (actions endpoint is JWT-tenant-bound)", () => {
const s = resolveCollectButtonState({ ...baseInput, isTenantAdmin: true, isCrossTenantView: true });
expect(s.enabled).toBe(false);
expect(s.tooltip).toMatch(/cross-tenant/);
});
it("shows a busy label and disables while a collection is pending", () => {
const queued = resolveCollectButtonState({ ...baseInput, isTenantAdmin: true, phase: "queued" });
expect(queued.enabled).toBe(false);
expect(queued.busyLabel).toMatch(/agent/i);
const collecting = resolveCollectButtonState({ ...baseInput, isTenantAdmin: true, phase: "collecting" });
expect(collecting.busyLabel).toMatch(/Collecting/);
});
});
describe("evaluateCollectProgress", () => {
const ev = (eventType: string, sequence: number, data?: Record<string, unknown>): CollectWatchEvent =>
({ eventType, sequence, data });
const baseWatch = {
phase: "queued" as const,
baselineSequence: 100,
baselineBlobName: undefined as string | undefined,
currentBlobName: undefined as string | undefined,
events: [] as CollectWatchEvent[],
};
it("is inert while idle", () => {
expect(evaluateCollectProgress({ ...baseWatch, phase: "idle" }).kind).toBe("none");
});
it("reports done when the session's blob name changes — even without visible events", () => {
const r = evaluateCollectProgress({
...baseWatch,
currentBlobName: "t/AgentDiagnostics-s-20260724T100000-server-requested.zip",
});
expect(r).toEqual({ kind: "done", blobName: "t/AgentDiagnostics-s-20260724T100000-server-requested.zip" });
});
it("reports done when an EXISTING blob name is replaced by a new one", () => {
const r = evaluateCollectProgress({
...baseWatch,
baselineBlobName: "t/old.zip",
currentBlobName: "t/new-server-requested.zip",
});
expect(r.kind).toBe("done");
});
it("does NOT report done while the blob name is unchanged from the baseline", () => {
const r = evaluateCollectProgress({
...baseWatch,
baselineBlobName: "t/old.zip",
currentBlobName: "t/old.zip",
});
expect(r.kind).toBe("none");
});
it("moves to collecting on server_action_received for request_diagnostics after the baseline", () => {
const r = evaluateCollectProgress({
...baseWatch,
events: [ev("server_action_received", 101, { actionType: "request_diagnostics" })],
});
expect(r.kind).toBe("collecting");
});
it("ignores server_action events at or before the baseline (at-least-once redelivery)", () => {
const r = evaluateCollectProgress({
...baseWatch,
events: [
ev("server_action_received", 100, { actionType: "request_diagnostics" }),
ev("server_action_failed", 99, { actionType: "request_diagnostics", failureReason: "old" }),
],
});
expect(r.kind).toBe("none");
});
it("ignores server_action events for OTHER action types", () => {
const r = evaluateCollectProgress({
...baseWatch,
events: [ev("server_action_received", 101, { actionType: "rotate_config" })],
});
expect(r.kind).toBe("none");
});
it("reports failed with the agent's failureReason", () => {
const r = evaluateCollectProgress({
...baseWatch,
events: [ev("server_action_failed", 102, { actionType: "request_diagnostics", failureReason: "url_host_rejected" })],
});
expect(r).toEqual({ kind: "failed", reason: "url_host_rejected" });
});
it("falls back to a generic reason when failureReason is missing", () => {
const r = evaluateCollectProgress({
...baseWatch,
events: [ev("server_action_failed", 102, { actionType: "request_diagnostics" })],
});
expect(r.kind).toBe("failed");
});
it("prefers done over a stale failure when the blob eventually landed", () => {
const r = evaluateCollectProgress({
...baseWatch,
currentBlobName: "t/new.zip",
events: [ev("server_action_failed", 102, { actionType: "request_diagnostics", failureReason: "transient" })],
});
expect(r.kind).toBe("done");
});
});
@@ -0,0 +1,144 @@
// Pure state helpers for the session-detail "Collect Logs" button. No React/JSX so
// vitest can pin the role/config gating matrix and the progress evaluation without
// rendering (same pattern as diagnosticsSasExpiry.ts).
export type CollectPhase = "idle" | "working" | "queued" | "collecting";
/** Session statuses in which the agent is (potentially) still alive and can receive actions. */
export const ACTIVE_SESSION_STATUSES = ["InProgress", "Pending", "Stalled"];
/** Give up waiting for agent feedback after this long; the package still appears later if the agent reconnects. */
export const COLLECT_TIMEOUT_MS = 3 * 60_000;
export interface CollectButtonInput {
sessionStatus?: string | null;
/** Cross-tenant (fleet drill-in) views cannot queue actions: the backend resolves the tenant strictly from the caller's JWT. */
isCrossTenantView: boolean;
isTenantAdmin: boolean;
isOperator: boolean;
diagnosticsConfigured: boolean;
phase: CollectPhase;
}
export interface CollectButtonState {
enabled: boolean;
/** When true, the click opens the quick-config dialog instead of queueing directly (Admin on an unconfigured tenant). */
opensQuickConfig: boolean;
/** Tooltip for the disabled states — the button stays visible so users learn the capability exists. */
tooltip?: string;
/** Progress label replacing the idle caption while a collection is running. */
busyLabel?: string;
}
export function resolveCollectButtonState(input: CollectButtonInput): CollectButtonState {
if (input.phase !== "idle") {
return {
enabled: false,
opensQuickConfig: false,
busyLabel:
input.phase === "collecting" ? "Collecting…"
: input.phase === "queued" ? "Waiting for agent…"
: "Requesting…",
};
}
const isActive = !!input.sessionStatus && ACTIVE_SESSION_STATUSES.includes(input.sessionStatus);
if (!isActive) {
return {
enabled: false,
opensQuickConfig: false,
tooltip: "The agent is no longer active for this session — logs can only be collected while an enrollment is running.",
};
}
if (input.isCrossTenantView) {
return {
enabled: false,
opensQuickConfig: false,
tooltip: "Not available in the cross-tenant view.",
};
}
if (!input.diagnosticsConfigured) {
if (input.isTenantAdmin) {
// Admin path: the click opens the quick-config dialog (enable hosted upload, then collect).
return { enabled: true, opensQuickConfig: true };
}
return {
enabled: false,
opensQuickConfig: false,
tooltip: "Diagnostics upload is not configured for this tenant — a Tenant Admin can enable it in Settings.",
};
}
if (input.isTenantAdmin || input.isOperator) {
return { enabled: true, opensQuickConfig: false };
}
return {
enabled: false,
opensQuickConfig: false,
tooltip: "Requires the Operator or Admin role.",
};
}
// ── Progress evaluation ────────────────────────────────────────────────────────────
/** Minimal event shape needed for progress tracking (subset of EnrollmentEvent). */
export interface CollectWatchEvent {
eventType: string;
sequence: number;
data?: Record<string, unknown>;
}
export interface CollectWatchInput {
phase: CollectPhase;
/** Highest event sequence at the moment the collection was requested — only newer events count. */
baselineSequence: number;
/** DiagnosticsBlobName at request time; ANY change means a fresh package landed. */
baselineBlobName?: string;
currentBlobName?: string;
events: CollectWatchEvent[];
}
export type CollectWatchResult =
| { kind: "none" }
| { kind: "collecting" }
| { kind: "done"; blobName: string }
| { kind: "failed"; reason: string };
/**
* Evaluates the live event stream + session delta for progress on a pending collection.
* Sequence-based (backend-assigned, monotonic) rather than timestamp-based so agent
* clock skew cannot hide or resurrect matches.
*/
export function evaluateCollectProgress(input: CollectWatchInput): CollectWatchResult {
if (input.phase !== "queued" && input.phase !== "collecting") return { kind: "none" };
// Ultimate goal first: the Sessions row carries a new package name (delivered via the
// SignalR sessionUpdate delta) — the Download button is live regardless of which
// server_action events made it into the visible stream.
if (input.currentBlobName && input.currentBlobName !== input.baselineBlobName) {
return { kind: "done", blobName: input.currentBlobName };
}
const relevant = input.events.filter(
(e) => (e.sequence ?? 0) > input.baselineSequence
&& e.data?.["actionType"] === "request_diagnostics",
);
const failed = relevant.find((e) => e.eventType === "server_action_failed");
if (failed) {
const reason = typeof failed.data?.["failureReason"] === "string" && failed.data["failureReason"]
? (failed.data["failureReason"] as string)
: "diagnostics upload failed";
return { kind: "failed", reason };
}
const started = relevant.some(
(e) => e.eventType === "server_action_received" || e.eventType === "server_action_executed",
);
if (started) return { kind: "collecting" };
return { kind: "none" };
}
@@ -1,6 +1,6 @@
"use client";
import { useEffect, useState } from "react";
import { useCallback, useEffect, useState } from "react";
import { api } from "@/lib/api";
import { dedupedAuthFetch } from "@/lib/dedupedAuthFetch";
@@ -8,6 +8,10 @@ interface UseSessionTenantConfigReturn {
showScriptOutput: boolean;
enableSoftwareInventoryAnalyzer: boolean;
enableIntegrityBypassAnalyzer: boolean;
/** Whether an on-demand diagnostics upload can succeed right now (drives the Collect Logs button). */
diagnosticsUploadConfigured: boolean;
/** Optimistic flip after a successful quick-config from the session page — avoids a refetch round-trip. */
markDiagnosticsConfigured: () => void;
}
/**
@@ -22,13 +26,14 @@ export function useSessionTenantConfig(
const [showScriptOutput, setShowScriptOutput] = useState(true);
const [enableSoftwareInventoryAnalyzer, setEnableSoftwareInventoryAnalyzer] = useState(false);
const [enableIntegrityBypassAnalyzer, setEnableIntegrityBypassAnalyzer] = useState(true);
const [diagnosticsUploadConfigured, setDiagnosticsUploadConfigured] = useState(false);
useEffect(() => {
if (!sessionTenantId) return;
let cancelled = false;
(async () => {
try {
// These three flags are exposed via the member-readable feature-flags endpoint so that
// These flags are exposed via the member-readable feature-flags endpoint so that
// Operators and Viewers can load session details without 403'ing on the admin-only
// full /api/config/{tenantId} response.
const res = await dedupedAuthFetch(api.config.featureFlags(sessionTenantId), getAccessToken);
@@ -38,10 +43,19 @@ export function useSessionTenantConfig(
setShowScriptOutput(cfg.showScriptOutput ?? true);
setEnableSoftwareInventoryAnalyzer(cfg.enableSoftwareInventoryAnalyzer ?? false);
setEnableIntegrityBypassAnalyzer(cfg.enableIntegrityBypassAnalyzer ?? true);
setDiagnosticsUploadConfigured(cfg.diagnosticsUploadConfigured ?? false);
} catch { /* non-fatal */ }
})();
return () => { cancelled = true; };
}, [sessionTenantId, getAccessToken]);
return { showScriptOutput, enableSoftwareInventoryAnalyzer, enableIntegrityBypassAnalyzer };
const markDiagnosticsConfigured = useCallback(() => setDiagnosticsUploadConfigured(true), []);
return {
showScriptOutput,
enableSoftwareInventoryAnalyzer,
enableIntegrityBypassAnalyzer,
diagnosticsUploadConfigured,
markDiagnosticsConfigured,
};
}
@@ -32,6 +32,7 @@ import VulnerabilityReportSection from "./components/VulnerabilityReportSection"
import IntegrityBypassSection from "./components/IntegrityBypassSection";
import AdminOverrideModal from "./components/AdminOverrideModal";
import ReportSessionModal from "./components/ReportSessionModal";
import CollectLogsButton from "./components/CollectLogsButton";
import { usePageSections } from "../../../hooks/usePageSections";
import { PageSectionItem } from "../../../contexts/SidebarContext";
import { InformationCircleIcon, ComputerDesktopIcon, PlayCircleIcon, SparklesIcon, ChartBarIcon, CodeBracketIcon, ArrowDownTrayIcon, ListBulletIcon, ClockIcon, ShieldCheckIcon } from "../../../lib/sidebarIcons";
@@ -403,6 +404,23 @@ export default function SessionDetailPage() {
</h1>
</div>
<div className="flex flex-wrap items-center gap-3">
{/* Always rendered (also disabled): the visible button is the nudge towards
configuring diagnostics upload. Admin capability includes a GA viewing a
session in their own tenant; cross-tenant is gated inside the component. */}
<CollectLogsButton
sessionId={sessionId}
effectiveTenantId={sessionTenantId || tenantId}
sessionStatus={session?.status}
diagnosticsBlobName={session?.diagnosticsBlobName}
isCrossTenantView={isCrossTenantView}
isTenantAdmin={!!user?.isTenantAdmin || !!user?.isGlobalAdmin}
isOperator={user?.role === 'Operator'}
diagnosticsConfigured={tenantConfig.diagnosticsUploadConfigured}
onDiagnosticsConfigured={tenantConfig.markDiagnosticsConfigured}
events={events}
getAccessToken={getAccessToken}
addNotification={addNotification}
/>
{session?.diagnosticsBlobName && (
<button
onClick={async () => {
@@ -95,6 +95,13 @@ interface TenantConfigContextValue {
// Core
config: TenantConfiguration | null;
loading: boolean;
/**
* True when the current user may CHANGE tenant configuration (Tenant Admin or Global Admin).
* Operators reach the settings area read-only: sections must render no Save/Reset bar and
* disable their inputs when this is false. Mirrors the backend write gate (PUT config is
* TenantAdminOrGA), so hiding the affordances is UX — the server enforces regardless.
*/
canEditConfig: boolean;
savingSection: string | null;
error: string | null;
setError: (e: string | null) => void;
@@ -320,6 +327,11 @@ export function TenantConfigProvider({ children }: { children: React.ReactNode }
const { getAccessToken, user, logout } = useAuth();
const { addNotification } = useNotifications();
// Operators reach the settings area read-only — only a Tenant Admin or Global Admin may
// change configuration. Drives section rendering (no Save/Reset bar, disabled inputs)
// and the defensive guard in saveConfiguration; the backend enforces the same gate.
const canEditConfig = user?.isTenantAdmin === true || user?.isGlobalAdmin === true;
// --- State (mirrors old page.tsx lines 35-112) ---
const [config, setConfig] = useState<TenantConfiguration | null>(null);
const [admins, setAdmins] = useState<TenantAdmin[]>([]);
@@ -429,7 +441,7 @@ export function TenantConfigProvider({ children }: { children: React.ReactNode }
useEffect(() => {
if (!tenantId) return;
// Operators (non-admin) only need feature flags, not the full config
// Operators reach the settings area read-only (no Save affordances); admins may edit.
const isAdminOrGA = user?.isTenantAdmin || user?.isGlobalAdmin;
const fetchConfiguration = async () => {
@@ -437,33 +449,34 @@ export function TenantConfigProvider({ children }: { children: React.ReactNode }
setLoading(true);
setError(null);
if (!isAdminOrGA) {
// Operator with canManageBootstrapTokens — load only feature flags
const response = await authenticatedFetch(api.config.featureFlags(tenantId), getAccessToken);
if (!response.ok) {
throw new Error(`Failed to load feature flags: ${response.statusText}`);
}
const flags = await response.json();
// Create a minimal config object with just the feature flag
setConfig({ bootstrapTokenEnabled: flags.bootstrapTokenEnabled } as TenantConfiguration);
setEditionInfo(parseEditionInfo(flags));
return;
}
// Admin path: full config + feature flags in parallel — the edition surface is
// resolved SERVER-side (trial expiry math), so it comes from flags, not the raw config.
// Everyone entitled to the settings area loads the full config + feature flags in
// parallel — the edition surface is resolved SERVER-side (trial expiry math), so it
// comes from flags, not the raw config. Non-admins (Operators) receive the
// server-REDACTED copy: the backend clears secrets for every caller without write
// authority over the target tenant, so showing it read-only is safe.
const [response, flagsResponse] = await Promise.all([
authenticatedFetch(api.config.tenant(tenantId), getAccessToken),
authenticatedFetch(api.config.featureFlags(tenantId), getAccessToken),
]);
let flags: unknown = null;
if (flagsResponse.ok) {
try {
setEditionInfo(parseEditionInfo(await flagsResponse.json()));
flags = await flagsResponse.json();
setEditionInfo(parseEditionInfo(flags));
} catch { /* fail-closed: keep Community default */ }
}
if (!response.ok) {
// Deploy-order safety net: a backend that still gates the config GET admin-tier
// 403s an Operator. Fall back to the feature-flags minimal view (pre-change
// behavior for non-admins) instead of surfacing a load error.
if (response.status === 403 && !isAdminOrGA && flags && typeof flags === "object") {
setConfig({
bootstrapTokenEnabled: (flags as { bootstrapTokenEnabled?: boolean }).bootstrapTokenEnabled,
} as TenantConfiguration);
return;
}
throw new Error(`Failed to load configuration: ${response.statusText}`);
}
@@ -652,7 +665,9 @@ export function TenantConfigProvider({ children }: { children: React.ReactNode }
// Save configuration (shared by all sections)
// -----------------------------------------------------------------------
const saveConfiguration = useCallback(async (sectionName: string, overrides?: { validateAutopilotDevice?: boolean; validateCorporateIdentifier?: boolean; validateDeviceAssociation?: boolean; unrestrictedMode?: boolean }): Promise<boolean> => {
if (!tenantId || !config) return false;
// Read-only viewers (Operators) have no save affordances; this guard covers any path
// that still reaches a save (the backend would 403 the PUT regardless).
if (!tenantId || !config || !canEditConfig) return false;
try {
setSavingSection(sectionName);
@@ -758,7 +773,7 @@ export function TenantConfigProvider({ children }: { children: React.ReactNode }
setSavingSection(null);
}
}, [
tenantId, config, getAccessToken, addNotification,
tenantId, config, canEditConfig, getAccessToken, addNotification,
manufacturerWhitelist, modelWhitelist, validateAutopilotDevice, validateCorporateIdentifier, validateDeviceAssociation,
dataRetentionDays, sessionTimeoutHours, enablePerformanceCollector, performanceCollectorInterval,
helloWaitTimeoutSeconds, selfDestructOnComplete, keepLogFile, rebootOnComplete, rebootDelaySeconds,
@@ -1494,7 +1509,7 @@ export function TenantConfigProvider({ children }: { children: React.ReactNode }
// -----------------------------------------------------------------------
return (
<TenantConfigContext.Provider value={{
config, loading, savingSection,
config, loading, canEditConfig, savingSection,
error, setError, successMessage, setSuccessMessage,
// Edition / trial
@@ -6,6 +6,7 @@ import AgentAnalyzersSection from "../../components/AgentAnalyzersSection";
export function SectionAgentAnalyzers() {
const {
canEditConfig,
enableLocalAdminAnalyzer, setEnableLocalAdminAnalyzer,
localAdminAllowedAccounts, setLocalAdminAllowedAccounts,
newAllowedAccount, setNewAllowedAccount,
@@ -41,6 +42,7 @@ export function SectionAgentAnalyzers() {
onSave={handleSaveAgentAnalyzers}
onReset={handleResetAgentAnalyzers}
saving={savingSection === "agentAnalyzers"}
readOnly={!canEditConfig}
/>
</>
);
@@ -6,6 +6,7 @@ import AgentSettingsSection from "../../components/AgentSettingsSection";
export function SectionAgentSettings() {
const {
canEditConfig,
enablePerformanceCollector, setEnablePerformanceCollector,
performanceCollectorInterval, setPerformanceCollectorInterval,
helloWaitTimeoutSeconds, setHelloWaitTimeoutSeconds,
@@ -65,6 +66,7 @@ export function SectionAgentSettings() {
onSave={handleSaveAgentSettings}
onReset={handleResetAgentSettings}
saving={savingSection === "agentSettings"}
readOnly={!canEditConfig}
/>
</>
);
@@ -6,6 +6,7 @@ import DiagnosticsSection from "../../components/DiagnosticsSection";
export function SectionDiagnostics() {
const {
canEditConfig,
diagnosticsBlobSasUrl, setDiagnosticsBlobSasUrl,
diagnosticsUploadMode, setDiagnosticsUploadMode,
diagnosticsUploadDestination, setDiagnosticsUploadDestination,
@@ -39,6 +40,7 @@ export function SectionDiagnostics() {
onSave={handleSaveDiagnostics}
onReset={handleResetDiagnostics}
saving={savingSection === "diagnostics"}
readOnly={!canEditConfig}
/>
</>
);
@@ -6,12 +6,22 @@ import UnrestrictedModeSection from "../../components/UnrestrictedModeSection";
export function SectionUnrestrictedMode() {
const {
config,
canEditConfig, config,
unrestrictedMode, setUnrestrictedMode,
handleSaveUnrestrictedMode,
savingSection,
} = useTenantConfig();
// Security-sensitive toggle — Operators never see it (with the unified config fetch
// they would otherwise see the GA-gate flag and get the section rendered).
if (!canEditConfig) {
return (
<div className="bg-amber-50 border border-amber-200 rounded-lg p-4 text-sm text-amber-800">
This page is available to tenant administrators only.
</div>
);
}
if (!config?.unrestrictedModeEnabled) {
return (
<div className="bg-white rounded-lg shadow p-8 text-center">
@@ -1,6 +1,7 @@
"use client";
import SaveResetBar from "./SaveResetBar";
import ReadOnlyFieldset from "./ReadOnlyFieldset";
interface AgentAnalyzersSectionProps {
enableLocalAdminAnalyzer: boolean;
@@ -22,6 +23,8 @@ interface AgentAnalyzersSectionProps {
onSave: () => Promise<void> | void;
onReset: () => void;
saving: boolean;
/** Read-only viewer (Operator): analyzers visible but inert, no Save/Reset bar. */
readOnly?: boolean;
}
const BUILTIN_ACCOUNTS = [
@@ -58,6 +61,7 @@ export default function AgentAnalyzersSection({
onSave,
onReset,
saving,
readOnly = false,
}: AgentAnalyzersSectionProps) {
const trimmed = newAllowedAccount.trim();
const isDuplicate =
@@ -89,6 +93,8 @@ export default function AgentAnalyzersSection({
</div>
</div>
<div className="p-6 space-y-6">
<ReadOnlyFieldset readOnly={readOnly}>
<div className="space-y-6">
{/* ── Local Admin Analyzer ─────────────────────────── */}
<div>
@@ -337,8 +343,11 @@ export default function AgentAnalyzersSection({
</div>
</div>
</div>
</ReadOnlyFieldset>
{/* Save / Reset */}
<SaveResetBar onSave={onSave} onReset={onReset} saving={saving} />
{!readOnly && <SaveResetBar onSave={onSave} onReset={onReset} saving={saving} />}
</div>
</div>
);
@@ -1,6 +1,7 @@
"use client";
import SaveResetBar from "./SaveResetBar";
import ReadOnlyFieldset from "./ReadOnlyFieldset";
interface AgentSettingsSectionProps {
enablePerformanceCollector: boolean;
@@ -38,6 +39,8 @@ interface AgentSettingsSectionProps {
onSave: () => Promise<void> | void;
onReset: () => void;
saving: boolean;
/** Read-only viewer (Operator): settings visible but inert, no Save/Reset bar. */
readOnly?: boolean;
}
export default function AgentSettingsSection({
@@ -76,6 +79,7 @@ export default function AgentSettingsSection({
onSave,
onReset,
saving,
readOnly = false,
}: AgentSettingsSectionProps) {
return (
<>
@@ -93,6 +97,8 @@ export default function AgentSettingsSection({
</div>
</div>
<div className="p-6 space-y-4">
<ReadOnlyFieldset readOnly={readOnly}>
<div className="space-y-4">
{/* Self-Destruct */}
<div className="flex items-center justify-between p-4 rounded-lg border border-gray-200 hover:border-violet-200 transition-colors">
@@ -291,8 +297,10 @@ export default function AgentSettingsSection({
</div>
</>
)}
</div>
</ReadOnlyFieldset>
<SaveResetBar onSave={onSave} onReset={onReset} saving={saving} />
{!readOnly && <SaveResetBar onSave={onSave} onReset={onReset} saving={saving} />}
</div>
</div>
@@ -310,6 +318,8 @@ export default function AgentSettingsSection({
</div>
</div>
<div className="p-6 space-y-5">
<ReadOnlyFieldset readOnly={readOnly} notice={false}>
<div className="space-y-5">
{/* Performance Collector */}
<div className="flex items-center justify-between p-4 rounded-lg border border-gray-200 hover:border-emerald-200 transition-colors">
<div className="flex-1">
@@ -370,6 +380,8 @@ export default function AgentSettingsSection({
</div>
</div>
</div>
</ReadOnlyFieldset>
</div>
</div>
</>
@@ -1,6 +1,7 @@
"use client";
import SaveResetBar from "./SaveResetBar";
import ReadOnlyFieldset from "./ReadOnlyFieldset";
interface DataManagementSectionProps {
dataRetentionDays: number;
@@ -13,6 +14,8 @@ interface DataManagementSectionProps {
onSave: () => Promise<void> | void;
onReset: () => void;
saving: boolean;
/** Read-only viewer (Operator): values visible but inert, no Save/Reset bar. */
readOnly?: boolean;
}
export default function DataManagementSection({
@@ -25,6 +28,7 @@ export default function DataManagementSection({
onSave,
onReset,
saving,
readOnly = false,
}: DataManagementSectionProps) {
const isOverridden = dataRetentionDays === 0 || dataRetentionDays < 7 || dataRetentionDays > retentionCapDays;
const isRetentionDisabled = isOverridden && !isGlobalAdmin;
@@ -43,6 +47,8 @@ export default function DataManagementSection({
</div>
</div>
<div className="p-6 space-y-6">
<ReadOnlyFieldset readOnly={readOnly}>
<div className="space-y-6">
{/* Data Retention Days */}
<div>
<label className="block">
@@ -106,7 +112,10 @@ export default function DataManagementSection({
</label>
</div>
<SaveResetBar onSave={onSave} onReset={onReset} saving={saving} />
</div>
</ReadOnlyFieldset>
{!readOnly && <SaveResetBar onSave={onSave} onReset={onReset} saving={saving} />}
</div>
</div>
);
@@ -5,6 +5,7 @@ import { DiagnosticsLogPath } from "../types";
import { validateDiagnosticsPath } from "@/utils/guardValidation";
import { ValidationIndicator } from "@/components/ValidationIndicator";
import SaveResetBar from "./SaveResetBar";
import ReadOnlyFieldset from "./ReadOnlyFieldset";
import { parseSasExpiry } from "./diagnosticsSasExpiry";
// Re-export for callers that still import from the section. Implementation lives
@@ -30,6 +31,8 @@ interface DiagnosticsSectionProps {
onSave: () => Promise<void> | void;
onReset: () => void;
saving: boolean;
/** Read-only viewer (Operator): settings visible but inert, no Save/Reset bar. */
readOnly?: boolean;
}
export default function DiagnosticsSection({
@@ -50,6 +53,7 @@ export default function DiagnosticsSection({
onSave,
onReset,
saving,
readOnly = false,
}: DiagnosticsSectionProps) {
const isHosted = diagnosticsUploadDestination === "Hosted";
const [newDiagSubfolders, setNewDiagSubfolders] = useState(false);
@@ -77,6 +81,8 @@ export default function DiagnosticsSection({
</div>
</div>
<div className="p-6 space-y-4">
<ReadOnlyFieldset readOnly={readOnly}>
<div className="space-y-4">
{/* Destination selector — explicit admin choice between customer-controlled SAS
(default, data stays in customer's storage) and hosted (opt-in, data leaves
@@ -364,7 +370,10 @@ export default function DiagnosticsSection({
</div>
</div>
<SaveResetBar onSave={onSave} onReset={onReset} saving={saving} />
</div>
</ReadOnlyFieldset>
{!readOnly && <SaveResetBar onSave={onSave} onReset={onReset} saving={saving} />}
</div>
</div>
);
@@ -28,6 +28,8 @@ interface HardwareRejectionInsightsProps {
webhookNotifyOnHardwareRejection: boolean;
onToggleNotification: (value: boolean) => void;
hasWebhook: boolean;
/** Read-only viewer (Operator): the notify toggle is disabled (insights stay browsable). */
readOnly?: boolean;
}
function formatDate(iso: string): string {
@@ -47,13 +49,16 @@ function WebhookToggle({
checked,
onChange,
hasWebhook,
disabled = false,
}: {
checked: boolean;
onChange: (value: boolean) => void;
hasWebhook: boolean;
disabled?: boolean;
}) {
const interactive = hasWebhook && !disabled;
return (
<label className={`flex items-center justify-between p-3 rounded-lg border border-gray-200 transition-colors ${hasWebhook ? "cursor-pointer hover:bg-gray-50" : "opacity-60 cursor-not-allowed"}`}>
<label className={`flex items-center justify-between p-3 rounded-lg border border-gray-200 transition-colors ${interactive ? "cursor-pointer hover:bg-gray-50" : "opacity-60 cursor-not-allowed"}`}>
<div className="flex items-center space-x-2">
<svg className="w-4 h-4 text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" />
@@ -72,7 +77,7 @@ function WebhookToggle({
type="checkbox"
checked={checked && hasWebhook}
onChange={(e) => onChange(e.target.checked)}
disabled={!hasWebhook}
disabled={!interactive}
className="sr-only peer"
/>
<div className="w-9 h-5 bg-gray-200 peer-focus:outline-none peer-focus:ring-2 peer-focus:ring-blue-500 rounded-full peer peer-checked:bg-blue-600 peer-disabled:opacity-50 transition-colors" />
@@ -89,6 +94,7 @@ export default function HardwareRejectionInsights({
webhookNotifyOnHardwareRejection,
onToggleNotification,
hasWebhook,
readOnly = false,
}: HardwareRejectionInsightsProps) {
const [data, setData] = useState<HardwareRejectedResponse | null>(null);
const [loading, setLoading] = useState(true);
@@ -189,6 +195,7 @@ export default function HardwareRejectionInsights({
checked={webhookNotifyOnHardwareRejection}
onChange={onToggleNotification}
hasWebhook={hasWebhook}
disabled={readOnly}
/>
</div>
@@ -2,6 +2,7 @@
import { useState } from "react";
import SaveResetBar from "./SaveResetBar";
import ReadOnlyFieldset from "./ReadOnlyFieldset";
interface HardwareWhitelistSectionProps {
manufacturerWhitelist: string;
@@ -11,6 +12,8 @@ interface HardwareWhitelistSectionProps {
onSave: () => Promise<void> | void;
onReset: () => void;
saving: boolean;
/** Read-only viewer (Operator): inputs disabled, no Save/Reset bar. */
readOnly?: boolean;
}
function parseList(csv: string): string[] {
@@ -130,6 +133,7 @@ export default function HardwareWhitelistSection({
onSave,
onReset,
saving,
readOnly = false,
}: HardwareWhitelistSectionProps) {
const manufacturers = parseList(manufacturerWhitelist);
const models = parseList(modelWhitelist);
@@ -153,6 +157,8 @@ export default function HardwareWhitelistSection({
</div>
</div>
<div className="p-6 space-y-5">
<ReadOnlyFieldset readOnly={readOnly}>
<div className="space-y-5">
{/* Info */}
<div className="bg-blue-50 border border-blue-200 rounded-lg p-3 space-y-2">
<p className="text-sm text-blue-900">
@@ -190,7 +196,10 @@ export default function HardwareWhitelistSection({
setItems={(items) => setModelWhitelist(joinList(items))}
/>
<SaveResetBar onSave={onSave} onReset={onReset} saving={saving} />
</div>
</ReadOnlyFieldset>
{!readOnly && <SaveResetBar onSave={onSave} onReset={onReset} saving={saving} />}
</div>
</div>
);
@@ -2,6 +2,7 @@
import { useEffect, useRef, useState } from "react";
import SaveResetBar from "./SaveResetBar";
import ReadOnlyFieldset from "./ReadOnlyFieldset";
import { MAX_NOTIFICATION_CHANNELS, NotificationChannel } from "../types";
interface NotificationsSectionProps {
@@ -13,6 +14,8 @@ interface NotificationsSectionProps {
onSave: () => Promise<void> | void;
onReset: () => void;
saving: boolean;
/** Read-only viewer (Operator): channels visible but inert, no Save/Reset bar. */
readOnly?: boolean;
}
const GENERIC_PROVIDER = 20;
@@ -330,6 +333,7 @@ export default function NotificationsSection({
onSave,
onReset,
saving,
readOnly = false,
}: NotificationsSectionProps) {
const addChannel = () => {
if (channels.length >= MAX_NOTIFICATION_CHANNELS) return;
@@ -363,6 +367,8 @@ export default function NotificationsSection({
</div>
</div>
<div className="p-6 space-y-4">
<ReadOnlyFieldset readOnly={readOnly}>
<div className="space-y-4">
{channels.length === 0 && (
<p className="text-sm text-gray-500">
No channels configured yet. Add a channel to receive enrollment, SLA, or rule notifications.
@@ -392,8 +398,10 @@ export default function NotificationsSection({
</svg>
Add channel{channels.length >= MAX_NOTIFICATION_CHANNELS ? ` (max ${MAX_NOTIFICATION_CHANNELS})` : ""}
</button>
</div>
</ReadOnlyFieldset>
<SaveResetBar onSave={onSave} onReset={onReset} saving={saving} />
{!readOnly && <SaveResetBar onSave={onSave} onReset={onReset} saving={saving} />}
</div>
</div>
);
@@ -0,0 +1,31 @@
"use client";
/**
* Wraps a settings section's interactive content for read-only viewers (Operators).
* The native fieldset `disabled` attribute inertly disables every nested input, select,
* textarea and button while values stay visible and copyable; a small notice explains why.
* Sections must ADDITIONALLY not render their SaveResetBar when read-only — a read-only
* viewer gets no save affordance at all, not a disabled one.
*/
export default function ReadOnlyFieldset({
readOnly,
notice = true,
children,
}: {
readOnly: boolean;
/** Set false on follow-up cards of the same page so the notice appears only once. */
notice?: boolean;
children: React.ReactNode;
}) {
if (!readOnly) return <>{children}</>;
return (
<fieldset disabled className="min-w-0">
{notice && (
<p className="mb-4 rounded-md bg-gray-50 border border-gray-200 px-3 py-2 text-sm text-gray-500">
Read-only view — configuration changes require a tenant administrator.
</p>
)}
{children}
</fieldset>
);
}
@@ -0,0 +1,146 @@
"use client";
import { useState, useEffect, useCallback } from "react";
import { api } from "@/lib/api";
import { authenticatedFetch } from "@/lib/authenticatedFetch";
interface AggregatedTpmPssUnsupported {
serialNumber: string;
manufacturer: string;
model: string;
attemptCount: number;
firstSeen: string;
lastSeen: string;
}
interface TpmPssUnsupportedResponse {
success: boolean;
aggregated: AggregatedTpmPssUnsupported[];
totalRawReports: number;
dataQualityNotice: string;
}
interface TpmIncompatibleDevicesInsightsProps {
getAccessToken: () => Promise<string | null>;
}
function formatDate(iso: string): string {
try {
return new Date(iso).toLocaleString(undefined, {
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
});
} catch {
return iso;
}
}
/**
* Devices whose TPM cannot perform RSA-PSS signing — Schannel silently drops their client
* certificate from TLS client-auth (Windows 11 25H2+ prefers PSS), so the agent can never
* authenticate. Remediation is a TPM firmware update or device replacement.
*
* Unlike the sibling insights panels this one renders nothing while loading, on error, and
* when no device is affected: it is a rare condition and a third permanent card in the
* section would be noise for the common all-clear case.
*/
export default function TpmIncompatibleDevicesInsights({
getAccessToken,
}: TpmIncompatibleDevicesInsightsProps) {
const [data, setData] = useState<TpmPssUnsupportedResponse | null>(null);
const fetchData = useCallback(async () => {
try {
const res = await authenticatedFetch(api.distress.tpmPssUnsupported(), getAccessToken);
if (!res.ok) return;
const json: TpmPssUnsupportedResponse = await res.json();
setData(json);
} catch {
// Silent degrade — the panel is a secondary insight; the section stays usable without it.
}
}, [getAccessToken]);
useEffect(() => {
fetchData();
}, [fetchData]);
if (!data || data.aggregated.length === 0) {
return null;
}
return (
<div className="bg-white rounded-lg shadow mt-6">
{/* Header */}
<div className="p-6 border-b border-gray-200 bg-gradient-to-r from-amber-50 to-orange-50">
<div className="flex items-center justify-between">
<div className="flex items-center space-x-2">
<svg className="w-6 h-6 text-amber-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L4.082 16.5c-.77.833.192 2.5 1.732 2.5z" />
</svg>
<div>
<h3 className="text-lg font-semibold text-gray-900">
Devices with Incompatible TPM
<span className="ml-2 text-xs font-normal text-gray-500">Last 14 days</span>
</h3>
<p className="text-sm text-gray-500 mt-0.5">
These devices have a TPM that cannot perform RSA-PSS signing, so the agent cannot
authenticate and no enrollment data is reported
</p>
</div>
</div>
<span className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-amber-100 text-amber-800">
Unverified
</span>
</div>
</div>
{/* Explanation + remediation */}
<div className="px-6 pt-4 space-y-3">
<div className="bg-amber-50 border border-amber-200 rounded-lg p-3">
<p className="text-xs text-amber-800">
Windows 11 25H2 and later prefer RSA-PSS for TLS client authentication. On these devices
the TPM-backed Intune certificate cannot sign with RSA-PSS (common on older TPM firmware),
so Windows silently withholds the certificate and every connection attempt is rejected.
Remediation: update the TPM firmware via the vendor&apos;s tooling, or replace the device.
This data comes from pre-authentication distress signals; serial number, manufacturer,
and model values are self-reported and unverified.
</p>
</div>
</div>
<div className="p-6">
<div className="overflow-x-auto">
<table className="min-w-full text-sm">
<thead>
<tr className="text-left text-xs text-gray-500 uppercase tracking-wider border-b border-gray-200">
<th className="pb-2 pr-4">Serial Number</th>
<th className="pb-2 pr-4">Manufacturer</th>
<th className="pb-2 pr-4">Model</th>
<th className="pb-2 pr-4 text-right">Attempts</th>
<th className="pb-2 pr-4">First Seen</th>
<th className="pb-2">Last Seen</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
{data.aggregated.map((row, idx) => (
<tr key={`${row.serialNumber}|${idx}`}>
<td className="py-3 pr-4 font-mono text-gray-900">{row.serialNumber || "–"}</td>
<td className="py-3 pr-4 text-gray-700">{row.manufacturer || "–"}</td>
<td className="py-3 pr-4 text-gray-700">{row.model || "–"}</td>
<td className="py-3 pr-4 text-right font-mono text-gray-900">{row.attemptCount}</td>
<td className="py-3 pr-4 text-gray-500 whitespace-nowrap">{formatDate(row.firstSeen)}</td>
<td className="py-3 text-gray-500 whitespace-nowrap">{formatDate(row.lastSeen)}</td>
</tr>
))}
</tbody>
</table>
</div>
<p className="text-xs text-gray-400 mt-3">
{data.totalRawReports} total report{data.totalRawReports !== 1 ? "s" : ""} across {data.aggregated.length} device{data.aggregated.length !== 1 ? "s" : ""}
</p>
</div>
</div>
);
}
@@ -6,6 +6,7 @@ import DataManagementSection from "../../components/DataManagementSection";
export function SectionDataManagement() {
const {
canEditConfig,
dataRetentionDays, setDataRetentionDays,
sessionTimeoutHours, setSessionTimeoutHours,
user, editionInfo,
@@ -26,6 +27,7 @@ export function SectionDataManagement() {
onSave={handleSaveDataManagement}
onReset={handleResetDataManagement}
saving={savingSection === "dataManagement"}
readOnly={!canEditConfig}
/>
</>
);
@@ -8,6 +8,7 @@ import OffboardingSection from "../../components/OffboardingSection";
export function SectionOffboarding() {
const {
canEditConfig,
showOffboardDialog, setShowOffboardDialog,
offboardConfirmText, setOffboardConfirmText,
offboarding, offboardError, setOffboardError,
@@ -17,6 +18,15 @@ export function SectionOffboarding() {
const { tenantId } = useTenant();
const { getAccessToken } = useAuth();
// Offboarding deletes the whole tenant — Operators never see the danger zone.
if (!canEditConfig) {
return (
<div className="bg-amber-50 border border-amber-200 rounded-lg p-4 text-sm text-amber-800">
This page is available to tenant administrators only.
</div>
);
}
return (
<>
<TenantNotifications />
@@ -6,6 +6,7 @@ import AdminManagementSection from "../../components/AdminManagementSection";
export function SectionAccessManagement() {
const {
canEditConfig,
admins, loadingAdmins,
newAdminEmail, setNewAdminEmail,
newMemberRole, setNewMemberRole,
@@ -17,6 +18,17 @@ export function SectionAccessManagement() {
handleToggleTenantAdmin, handleUpdatePermissions,
} = useTenantConfig();
// Operators do not manage members, and the member list itself is admin-tier data
// (the backend never serves it to them) — hide the section instead of rendering
// an empty table with dead controls.
if (!canEditConfig) {
return (
<div className="bg-amber-50 border border-amber-200 rounded-lg p-4 text-sm text-amber-800">
This page is available to tenant administrators only.
</div>
);
}
return (
<>
<TenantNotifications />
@@ -8,6 +8,7 @@ import NotRegisteredDevicesInsights from "../../components/NotRegisteredDevicesI
export function SectionAutopilotValidation() {
const {
canEditConfig,
validateAutopilotDevice, setValidateAutopilotDevice,
validateCorporateIdentifier, setValidateCorporateIdentifier,
validateDeviceAssociation,
@@ -17,6 +18,16 @@ export function SectionAutopilotValidation() {
} = useTenantConfig();
const { user, getAccessToken } = useAuth();
// Validation gates + the Entra admin-consent flow are tenant-admin territory —
// Operators do not see this section at all.
if (!canEditConfig) {
return (
<div className="bg-amber-50 border border-amber-200 rounded-lg p-4 text-sm text-amber-800">
This page is available to tenant administrators only.
</div>
);
}
// DevPrep "Device association" is in Microsoft Private Preview — surface only to Global Admins
// until it ships GA. Backend always rejects writes from non-GA callers regardless of UI.
// TODO(devprep-followup): add vitest DOM-render test asserting toggle is hidden for
@@ -2,9 +2,11 @@
import { useTenantConfig } from "../../TenantConfigContext";
import SaveResetBar from "../../components/SaveResetBar";
import ReadOnlyFieldset from "../../components/ReadOnlyFieldset";
export function SectionContact() {
const {
canEditConfig,
contactEmail, setContactEmail,
handleSaveContact, handleResetContact,
savingSection,
@@ -28,6 +30,7 @@ export function SectionContact() {
</div>
<div className="p-6 space-y-4">
<ReadOnlyFieldset readOnly={!canEditConfig}>
<div>
<label htmlFor="contactEmail" className="block text-sm font-medium text-gray-700">
Contact email address
@@ -37,13 +40,14 @@ export function SectionContact() {
type="email"
value={contactEmail}
onChange={(e) => setContactEmail(e.target.value)}
placeholder="it-operations@contoso.com"
className="mt-1 block w-full max-w-md rounded-md border border-gray-300 px-3 py-2 text-sm shadow-sm focus:border-indigo-500 focus:ring-indigo-500"
placeholder={canEditConfig ? "it-operations@contoso.com" : "Not configured"}
className="mt-1 block w-full max-w-md rounded-md border border-gray-300 px-3 py-2 text-sm shadow-sm focus:border-indigo-500 focus:ring-indigo-500 disabled:bg-gray-50 disabled:text-gray-600"
/>
{looksInvalid && (
<p className="mt-1 text-sm text-amber-600">That does not look like an email address.</p>
)}
</div>
</ReadOnlyFieldset>
<div className="rounded-md bg-blue-50 border border-blue-100 p-4">
<p className="text-sm text-blue-900">
@@ -57,12 +61,14 @@ export function SectionContact() {
</p>
</div>
<SaveResetBar
onSave={handleSaveContact}
onReset={handleResetContact}
saving={savingSection === "contact"}
canSave={!looksInvalid}
/>
{canEditConfig && (
<SaveResetBar
onSave={handleSaveContact}
onReset={handleResetContact}
saving={savingSection === "contact"}
canSave={!looksInvalid}
/>
)}
</div>
</div>
);
@@ -6,6 +6,7 @@ import { useTenantConfig } from "../../TenantConfigContext";
import { TenantNotifications } from "../../TenantNotifications";
import HardwareWhitelistSection from "../../components/HardwareWhitelistSection";
import HardwareRejectionInsights from "../../components/HardwareRejectionInsights";
import TpmIncompatibleDevicesInsights from "../../components/TpmIncompatibleDevicesInsights";
function parseList(csv: string): string[] {
return csv.split(",").map((s) => s.trim()).filter(Boolean);
@@ -18,6 +19,7 @@ function joinList(items: string[]): string {
export function SectionHardwareWhitelist() {
const { getAccessToken } = useAuth();
const {
canEditConfig,
manufacturerWhitelist, setManufacturerWhitelist,
modelWhitelist, setModelWhitelist,
webhookNotifyOnHardwareRejection, setWebhookNotifyOnHardwareRejection,
@@ -53,15 +55,18 @@ export function SectionHardwareWhitelist() {
onSave={handleSaveHardwareWhitelist}
onReset={handleResetHardwareWhitelist}
saving={savingSection === "hardwareWhitelist"}
readOnly={!canEditConfig}
/>
<HardwareRejectionInsights
getAccessToken={getAccessToken}
onAddManufacturer={handleAddManufacturer}
onAddModel={handleAddModel}
onAddManufacturer={canEditConfig ? handleAddManufacturer : undefined}
onAddModel={canEditConfig ? handleAddModel : undefined}
webhookNotifyOnHardwareRejection={webhookNotifyOnHardwareRejection}
onToggleNotification={setWebhookNotifyOnHardwareRejection}
hasWebhook={hasWebhook}
readOnly={!canEditConfig}
/>
<TpmIncompatibleDevicesInsights getAccessToken={getAccessToken} />
</>
);
}
@@ -6,6 +6,7 @@ import NotificationsSection from "../../components/NotificationsSection";
export function SectionNotifications() {
const {
canEditConfig,
notificationChannels, setNotificationChannels,
handleTestChannel, testingChannelId, testChannelResult,
handleSaveNotifications, handleResetNotifications,
@@ -24,6 +25,7 @@ export function SectionNotifications() {
onSave={handleSaveNotifications}
onReset={handleResetNotifications}
saving={savingSection === "notifications"}
readOnly={!canEditConfig}
/>
</>
);
@@ -28,11 +28,19 @@ function CheckIcon({ className }: { className: string }) {
);
}
function FeatureList({ features, checkClass }: { features: string[]; checkClass: string }) {
function PlusIcon({ className }: { className: string }) {
return (
<svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={2.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 5v14M5 12h14" />
</svg>
);
}
function FeatureList({ features, checkClass, muted = false }: { features: string[]; checkClass: string; muted?: boolean }) {
return (
<ul className="space-y-2.5">
{features.map((f) => (
<li key={f} className="flex items-start gap-2 text-sm text-gray-700">
<li key={f} className={`flex items-start gap-2 text-sm ${muted ? "text-gray-600" : "text-gray-700"}`}>
<CheckIcon className={`w-4 h-4 mt-0.5 shrink-0 ${checkClass}`} />
<span>{f}</span>
</li>
@@ -41,9 +49,29 @@ function FeatureList({ features, checkClass }: { features: string[]; checkClass:
);
}
/** The Enterprise delta: same layout as FeatureList but "+" bullets and emphasized text. */
function PlusList({ features }: { features: string[] }) {
return (
<ul className="space-y-2.5">
{features.map((f) => (
<li key={f} className="flex items-start gap-2 text-sm font-medium text-gray-900">
<PlusIcon className="w-4 h-4 mt-0.5 shrink-0 text-purple-600 dark:text-purple-400" />
<span>{f}</span>
</li>
))}
</ul>
);
}
/**
* Plan section: two side-by-side plan cards (Community and Enterprise). The tenant's current plan
* is highlighted so the edition is obvious at a glance; the Enterprise card teases what it adds.
*
* Dark mode: the tinted card surfaces use slash-opacity utilities (bg-purple-50/40), which the
* global `.dark .bg-*` override map in globals.css does NOT match — they are separate class tokens.
* Without the explicit `dark:` counterparts below the cards render as a washed-out light overlay on
* the dark page. Secondary copy uses text-gray-600 rather than -400/-500: both of those collapse to
* the same dim #64748b in dark mode, which fails contrast on a tinted card.
*/
export function SectionPlan() {
const { editionInfo, startTrial, startingTrial, user } = useTenantConfig();
@@ -55,16 +83,23 @@ export function SectionPlan() {
const canStartTrial =
editionInfo.trialAvailable && (user?.isTenantAdmin === true || user?.isGlobalAdmin === true);
const communityFeatures = [
// Features shared by both plans verbatim. Plan-specific items (retention, support tier) are NOT
// in here — repeating "90-day retention" on the Enterprise card would be factually wrong; their
// Enterprise counterparts live in enterpriseExtras instead.
const sharedFeatures = [
"Live session monitoring & progress portal",
"Full rules engine, including custom rules",
"Fleet analytics, notifications & diagnostics",
"AI integration (MCP) within usage limits",
];
const communityFeatures = [
...sharedFeatures,
`${COMMUNITY_RETENTION_DAYS}-day data retention`,
"Community support (GitHub)",
];
const enterpriseFeatures = [
const enterpriseExtras = [
`Extended data retention — ${ENTERPRISE_RETENTION_DAYS} days (vs ${COMMUNITY_RETENTION_DAYS})`,
"Higher portal & agent API rate limits",
"Larger AI (MCP) usage quota",
@@ -91,16 +126,18 @@ export function SectionPlan() {
{/* Community card */}
<div
className={`rounded-xl border p-6 flex flex-col ${
!isEnterprise ? "border-gray-800 ring-1 ring-gray-800 bg-gray-50/60" : "border-gray-200"
!isEnterprise
? "border-gray-800 ring-1 ring-gray-800 bg-gray-50/60 dark:border-slate-500 dark:ring-slate-500 dark:bg-slate-900/40"
: "border-gray-200"
}`}
>
<div className="flex items-start justify-between gap-2">
<div>
<h3 className="text-lg font-semibold text-gray-900">Community</h3>
<p className="text-sm text-gray-500 mt-0.5">The full product, free</p>
<p className="text-sm text-gray-600 mt-0.5">The full product, free</p>
</div>
{!isEnterprise && (
<span className="inline-flex items-center px-2.5 py-1 rounded-full text-xs font-semibold bg-gray-900 text-white">
<span className="inline-flex items-center px-2.5 py-1 rounded-full text-xs font-semibold bg-gray-900 text-white dark:bg-slate-200 dark:text-slate-900">
Current plan
</span>
)}
@@ -108,7 +145,7 @@ export function SectionPlan() {
<div className="mt-4 mb-5">
<span className="text-2xl font-bold text-gray-900">Free</span>
<span className="text-sm text-gray-500"> — and stays free</span>
<span className="text-sm text-gray-600"> — and stays free</span>
</div>
<FeatureList features={communityFeatures} checkClass="text-emerald-500" />
@@ -117,13 +154,15 @@ export function SectionPlan() {
{/* Enterprise card */}
<div
className={`rounded-xl border p-6 flex flex-col ${
isEnterprise ? "border-purple-500 ring-1 ring-purple-500 bg-purple-50/40" : "border-purple-200 bg-purple-50/20"
isEnterprise
? "border-purple-500 ring-1 ring-purple-500 bg-purple-50/40 dark:bg-purple-950/40"
: "border-purple-200 bg-purple-50/20 dark:bg-purple-950/20"
}`}
>
<div className="flex items-start justify-between gap-2">
<div>
<h3 className="text-lg font-semibold text-purple-900">Enterprise</h3>
<p className="text-sm text-gray-500 mt-0.5">Higher limits, support & MSP</p>
<p className="text-sm text-gray-600 mt-0.5">Higher limits, support & MSP</p>
</div>
{isEnterprise ? (
<span className="inline-flex items-center px-2.5 py-1 rounded-full text-xs font-semibold bg-purple-600 text-white">
@@ -140,19 +179,26 @@ export function SectionPlan() {
<span className="text-2xl font-bold text-purple-900">
{isEnterprise ? "Active" : "Pricing TBA"}
</span>
{!isEnterprise && <span className="text-sm text-gray-500"> — to be announced</span>}
{!isEnterprise && <span className="text-sm text-gray-600"> — to be announced</span>}
</div>
<p className="text-xs font-medium uppercase tracking-wide text-gray-400 mb-2.5">
Everything in Community, plus
<p className="text-xs font-medium uppercase tracking-wide text-gray-600 mb-2.5">
Everything in Community
</p>
<FeatureList features={enterpriseFeatures} checkClass="text-purple-500" />
<FeatureList features={sharedFeatures} checkClass="text-gray-500" muted />
<div className="flex items-center gap-3 my-4" aria-hidden="true">
<span className="h-px flex-1 bg-purple-200 dark:bg-purple-800" />
<span className="text-xs font-semibold uppercase tracking-wide text-purple-600 dark:text-purple-300">Plus</span>
<span className="h-px flex-1 bg-purple-200 dark:bg-purple-800" />
</div>
<PlusList features={enterpriseExtras} />
{/* CTA — only meaningful while the tenant is on Community */}
{!isEnterprise && (
<div className="mt-auto pt-5">
{trialConsumed ? (
<p className="text-sm text-gray-500">
<p className="text-sm text-gray-600">
This tenant has already used its Enterprise trial. To move to Enterprise,{" "}
<a
href="https://github.com/okieselbach/Autopilot-Monitor/issues"
@@ -210,7 +256,7 @@ export function SectionPlan() {
</div>
</div>
<p className="text-xs text-gray-400 mt-5">
<p className="text-xs text-gray-600 mt-5">
{isEnterprise && editionInfo.isTrial
? "When the trial ends, the tenant returns to Community automatically."
: "Scope, pricing and timeline for Enterprise will be announced. Community stays free."}
@@ -2,10 +2,12 @@
import { useTenantConfig } from "../../TenantConfigContext";
import SaveResetBar from "../../components/SaveResetBar";
import ReadOnlyFieldset from "../../components/ReadOnlyFieldset";
import { trackEvent } from "@/lib/appInsights";
export function SectionSlaTargets() {
const {
canEditConfig,
slaTargetSuccessRate, setSlaTargetSuccessRate,
slaTargetMaxDurationMinutes, setSlaTargetMaxDurationMinutes,
slaTargetAppInstallSuccessRate, setSlaTargetAppInstallSuccessRate,
@@ -37,6 +39,8 @@ export function SectionSlaTargets() {
</div>
</div>
<div className="p-6 space-y-8">
<ReadOnlyFieldset readOnly={!canEditConfig}>
<div className="space-y-8">
{/* SLA Targets */}
<div>
<p className="text-sm text-gray-500 mb-4">
@@ -216,21 +220,26 @@ export function SectionSlaTargets() {
</div>
</div>
<SaveResetBar
onSave={() => {
trackEvent('sla_targets_saved', {
hasSuccessRateTarget: slaTargetSuccessRate != null,
hasDurationTarget: slaTargetMaxDurationMinutes != null,
hasAppInstallTarget: slaTargetAppInstallSuccessRate != null,
});
return handleSaveSlaTargets();
}}
onReset={() => {
trackEvent('sla_targets_reset');
return handleResetSlaTargets();
}}
saving={savingSection === "slaTargets"}
/>
</div>
</ReadOnlyFieldset>
{canEditConfig && (
<SaveResetBar
onSave={() => {
trackEvent('sla_targets_saved', {
hasSuccessRateTarget: slaTargetSuccessRate != null,
hasDurationTarget: slaTargetMaxDurationMinutes != null,
hasAppInstallTarget: slaTargetAppInstallSuccessRate != null,
});
return handleSaveSlaTargets();
}}
onReset={() => {
trackEvent('sla_targets_reset');
return handleResetSlaTargets();
}}
saving={savingSection === "slaTargets"}
/>
)}
</div>
</div>
);
@@ -227,6 +227,12 @@ export function GlobalSidebar({ children }: { children: ReactNode }) {
if (sub.id === "cfg-agent-unrestricted") {
return isAdminLike && unrestrictedModeEnabled;
}
// Tenant-admin-only sub-sections: Operators (read-only settings viewers) don't
// see them — matches the in-page "tenant administrators only" gates. A platform
// scope (GA / read-only GlobalReader) keeps the full GA-identical sidebar.
if (sub.id === "cfg-autopilot" || sub.id === "cfg-access-mgmt" || sub.id === "cfg-offboarding") {
return isAdminLike || hasGlobalScope;
}
return true;
});
return { ...item, items: filteredSubs };
@@ -65,12 +65,12 @@ export default function ScriptExecutions({ events, showScriptOutput, latestBoots
onClick={() => setExpanded(!expanded)}
className="flex items-center justify-between w-full text-left"
>
<div className="flex items-center space-x-2">
<div className="flex flex-wrap items-center gap-2 min-w-0">
<svg className="w-5 h-5 text-violet-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 9l3 3-3 3m5 0h3M5 20h14a2 2 0 002-2V6a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
<h2 className="text-lg font-semibold text-gray-900">Script Executions</h2>
<div className="flex items-center space-x-2 text-xs">
<div className="flex flex-wrap items-center gap-2 text-xs">
{runningCount > 0 && (
<span className="px-2 py-0.5 rounded-full bg-gray-100 text-gray-700 font-medium">
{runningCount} running
@@ -215,10 +215,10 @@ function ScriptCardView({ card, showScriptOutput, latestBootstrapVersion, displa
<div className={`rounded-lg ${containerClass}`}>
<button
onClick={() => setPhasesOpen(!phasesOpen)}
className="flex items-center justify-between w-full text-left p-3 hover:bg-black/[.02]"
className="flex flex-wrap items-center justify-between gap-y-1 w-full text-left p-3 hover:bg-black/[.02]"
aria-expanded={phasesOpen}
>
<div className="flex items-center space-x-2 min-w-0">
<div className="flex flex-wrap items-center gap-x-2 gap-y-1 min-w-0">
<CardIcon state={card.headerState} className={iconColor} />
<span className="text-sm font-medium text-gray-900 truncate">Remediation</span>
{cardDisplayName && (
@@ -369,8 +369,8 @@ function ScriptItemRow({ item, showScriptOutput, latestBootstrapVersion, nested,
return (
<div className={`rounded-lg p-3 ${containerClass}`}>
<div className="flex items-center justify-between">
<div className="flex items-center space-x-2 min-w-0">
<div className="flex flex-wrap items-center justify-between gap-y-1">
<div className="flex flex-wrap items-center gap-x-2 gap-y-1 min-w-0">
{item.state === "Running" ? (
<svg className={`w-4 h-4 text-gray-500 flex-shrink-0 ${isStale ? "" : "animate-spin"}`} fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
+12
View File
@@ -54,6 +54,10 @@ export const api = {
`${API_BASE_URL}/api/sessions/${sessionId}/mark-failed${qs({ tenantId })}`,
markSucceeded: (sessionId: string, tenantId?: string) =>
`${API_BASE_URL}/api/sessions/${sessionId}/mark-succeeded${qs({ tenantId })}`,
// POST { type, reason } — queues a ServerAction delivered with the agent's next ingest
// response (e.g. request_diagnostics for the "Collect Logs" button).
queueAction: (sessionId: string, tenantId?: string) =>
`${API_BASE_URL}/api/sessions/${sessionId}/actions${qs({ tenantId })}`,
report: (sessionId: string, tenantId?: string) =>
`${API_BASE_URL}/api/sessions/${sessionId}/report${qs({ tenantId })}`,
quickSearch: (q: string) =>
@@ -189,12 +193,19 @@ export const api = {
`${API_BASE_URL}/api/rules/analyze/${encodeURIComponent(ruleId)}/create-from-template`,
globalAnalyze: (tenantId?: string) =>
`${API_BASE_URL}/api/global/rules/analyze${qs({ tenantId })}`,
// Cross-tenant edit/toggle/delete of an existing analyze rule (Global Admin override). PUT/DELETE
// to the JWT-scoped analyzeRule would misroute to the GA's OWN tenant; this carries the target tenant.
globalAnalyzeRule: (ruleId: string, tenantId?: string) =>
`${API_BASE_URL}/api/global/rules/analyze/${encodeURIComponent(ruleId)}${qs({ tenantId })}`,
gather: (tenantId?: string) =>
`${API_BASE_URL}/api/rules/gather${qs({ tenantId })}`,
gatherRule: (ruleId: string, tenantId?: string) =>
`${API_BASE_URL}/api/rules/gather/${ruleId}${qs({ tenantId })}`,
globalGather: (tenantId?: string) =>
`${API_BASE_URL}/api/global/rules/gather${qs({ tenantId })}`,
// Cross-tenant edit/toggle/delete of an existing gather rule (Global Admin override) — see globalAnalyzeRule.
globalGatherRule: (ruleId: string, tenantId?: string) =>
`${API_BASE_URL}/api/global/rules/gather/${encodeURIComponent(ruleId)}${qs({ tenantId })}`,
reseedFromGitHub: (type: "analyze" | "gather" | "ime" | "all") =>
`${API_BASE_URL}/api/rules/reseed-from-github${qs({ type })}`,
imeLogPatterns: () => `${API_BASE_URL}/api/rules/ime-log-patterns`,
@@ -405,6 +416,7 @@ export const api = {
distress: {
hardwareRejected: () => `${API_BASE_URL}/api/audit/hardware-rejected`,
deviceNotRegistered: () => `${API_BASE_URL}/api/audit/device-not-registered`,
tpmPssUnsupported: () => `${API_BASE_URL}/api/audit/tpm-pss-unsupported`,
},
// ── Ops Events ───────────────────────────────────────────────────────────
+11 -50
View File
@@ -32,11 +32,14 @@
"autoprefixer": "^10.5.4",
"eslint": "^9.39.5",
"eslint-config-next": "^15.5.21",
"postcss": "^8.5.10",
"postcss": "^8.5.12",
"tailwindcss": "^3.4.17",
"typescript": "^5.7.3",
"vitest": "^4.1.10"
},
"engines": {
"node": "22.x"
},
"optionalDependencies": {
"@next/swc-win32-x64-msvc": "npm:null@*"
}
@@ -1135,9 +1138,6 @@
"cpu": [
"arm64"
],
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1154,9 +1154,6 @@
"cpu": [
"arm64"
],
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1173,9 +1170,6 @@
"cpu": [
"x64"
],
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1192,9 +1186,6 @@
"cpu": [
"x64"
],
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1397,9 +1388,6 @@
"arm64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1417,9 +1405,6 @@
"arm64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1437,9 +1422,6 @@
"ppc64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1457,9 +1439,6 @@
"s390x"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1477,9 +1456,6 @@
"x64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1497,9 +1473,6 @@
"x64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -5642,9 +5615,6 @@
"arm64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -5666,9 +5636,6 @@
"arm64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -5690,9 +5657,6 @@
"x64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -5714,9 +5678,6 @@
"x64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -5930,9 +5891,9 @@
}
},
"node_modules/nanoid": {
"version": "3.3.11",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
"integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==",
"version": "3.3.16",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz",
"integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==",
"funding": [
{
"type": "github",
@@ -6396,9 +6357,9 @@
}
},
"node_modules/postcss": {
"version": "8.5.10",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.10.tgz",
"integrity": "sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ==",
"version": "8.5.22",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.22.tgz",
"integrity": "sha512-KBDEIpLrvpv16pp3K0Fw+UCoZfopFjjgeB+0tA/aaThfEE74kKDLrgg603YvOWJyg3+WYtyq3xYsQWsIyZlPqQ==",
"funding": [
{
"type": "opencollective",
@@ -6415,7 +6376,7 @@
],
"license": "MIT",
"dependencies": {
"nanoid": "^3.3.11",
"nanoid": "^3.3.16",
"picocolors": "^1.1.1",
"source-map-js": "^1.2.1"
},
+2 -2
View File
@@ -39,7 +39,7 @@
"autoprefixer": "^10.5.4",
"eslint": "^9.39.5",
"eslint-config-next": "^15.5.21",
"postcss": "^8.5.10",
"postcss": "^8.5.12",
"tailwindcss": "^3.4.17",
"typescript": "^5.7.3",
"vitest": "^4.1.10"
@@ -48,7 +48,7 @@
"@next/swc-win32-x64-msvc": "npm:null@*"
},
"overrides": {
"postcss": "^8.5.10",
"postcss": "^8.5.12",
"@nevware21/ts-utils": "^0.14.0",
"sharp": "^0.35.0"
}