mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-26 05:29:43 +00:00
Compare commits
14 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c7ed3ec27d | |||
| f311e8746b | |||
| e0b6ce83cf | |||
| 7f7b590db4 | |||
| 4c2c447a2e | |||
| 8a794a1b52 | |||
| 6c53dc33e7 | |||
| 801891d57a | |||
| 1f6efca95b | |||
| 9ec8644be2 | |||
| 9acd10e1dc | |||
| b382e5678e | |||
| 2fee50276e | |||
| 8bb02acc4a |
@@ -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.
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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).
|
||||
|
||||
+34
@@ -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;
|
||||
|
||||
+87
-28
@@ -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]
|
||||
|
||||
+4
-4
@@ -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;
|
||||
}
|
||||
}
|
||||
+304
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
+31
@@ -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)]
|
||||
|
||||
+53
-4
@@ -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}";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+8
-5
@@ -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.
|
||||
|
||||
+8
@@ -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}",
|
||||
|
||||
+95
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+3
-3
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+39
-1
@@ -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),
|
||||
|
||||
+15
-2
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
+34
@@ -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.
|
||||
|
||||
+50
@@ -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,
|
||||
|
||||
+19
-5
@@ -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'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>
|
||||
|
||||
+320
@@ -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 & 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'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 & Collect
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
+11
-2
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
+183
@@ -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");
|
||||
});
|
||||
});
|
||||
+144
@@ -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" };
|
||||
}
|
||||
+17
-3
@@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
+11
-1
@@ -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>
|
||||
);
|
||||
}
|
||||
+146
@@ -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'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>
|
||||
);
|
||||
}
|
||||
+2
@@ -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 />
|
||||
|
||||
+12
@@ -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 />
|
||||
|
||||
+11
@@ -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>
|
||||
);
|
||||
|
||||
+7
-2
@@ -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>
|
||||
|
||||
@@ -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
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user