session annotations: role-separated human verdicts on sessions (flywheel labels)

New SessionAnnotations table (PK=tenantId, RK={sessionId}_{lane}, lanes
operator/tenantadmin/globaladmin) storing structured verdicts + 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; the globaladmin lane is platform-internal and filtered
from tenant callers. Portal: SessionAnnotationsCard on session detail.
MCP: list_session_annotations (ga) + annotate_session (strictGa, GA lane
only) + annotations key in get_session_summary. Lifecycle: cascade steps
17-19, offboarding wipe, critical-backup member. Privacy page gains the
customer-entered-content line.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Oliver Kieselbach
2026-08-11 13:30:44 +02:00
parent 2f8ad82b0a
commit 2c48968ec6
29 changed files with 2366 additions and 21 deletions
+98
View File
@@ -0,0 +1,98 @@
---
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.
* **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.
# 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`.
+1
View File
@@ -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
+4
View File
@@ -1,5 +1,9 @@
# Log
## 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).
@@ -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>()))
@@ -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);
}
}
@@ -0,0 +1,144 @@
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);
}
}
@@ -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>();
@@ -0,0 +1,238 @@
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)
{
var filter = BuildQueryFilter(tenantId, lane, verdict, dateFrom, dateTo);
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)
{
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!)}'");
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);
}
}
@@ -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,
};
}
}
@@ -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;
}
}
}
}
@@ -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;
}
}
}
@@ -0,0 +1,149 @@
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 static string BuildNextLink(Parsed parsed, string wireContinuation)
{
var sb = new StringBuilder("/api/global/session-annotations");
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,10 @@ 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).
new("GET", "sessions/{sessionId}/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 +301,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 +457,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
@@ -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;
}
@@ -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).
@@ -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([]);
});
});
@@ -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,47 @@
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>
Task<(List<SessionAnnotation> Items, string? NextRawToken)> QueryPageAsync(
string? tenantId,
string? lane,
string? verdict,
string? ruleId,
DateTime? dateFrom,
DateTime? dateTo,
int pageSize,
string? continuation);
}
}
@@ -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,
};
}
}
@@ -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&apos;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>
@@ -0,0 +1,279 @@
"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_LABELS,
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;
}
const VERDICT_PILL: 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",
};
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>>>({});
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",
});
}
};
return (
<div className="bg-white shadow rounded-lg p-6 mb-6">
<div className="flex items-center justify-between mb-1">
<h2 className="text-lg font-medium text-gray-900">Annotations</h2>
</div>
<p className="text-sm text-gray-500 mb-4">
Human assessment of this enrollment&apos;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 className={`inline-flex px-2 py-0.5 rounded-full text-xs font-medium ${VERDICT_PILL[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}
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[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>
);
}
@@ -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,117 @@
/**
* 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",
};
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">
+7
View File
@@ -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
@@ -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}>