From 2c48968ec6b9ee5cd92de4ef5155ef3c4ac2da0e Mon Sep 17 00:00:00 2001 From: Oliver Kieselbach Date: Tue, 11 Aug 2026 13:30:44 +0200 Subject: [PATCH] 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 --- docs/backend/session-annotations.md | 98 +++++ docs/index.md | 1 + docs/log.md | 4 + .../CriticalTableBackupServiceTests.cs | 2 +- .../DeletionManifestBuilderTests.cs | 43 +- .../SessionAnnotationFunctionsTests.cs | 383 ++++++++++++++++++ .../TableSessionAnnotationRepositoryTests.cs | 144 +++++++ .../DataAccess/DataAccessServiceExtensions.cs | 1 + .../TableSessionAnnotationRepository.cs | 238 +++++++++++ .../GetSessionAnnotationsFunction.cs | 125 ++++++ .../ListSessionAnnotationsFunction.cs | 95 +++++ .../UpsertSessionAnnotationFunction.cs | 274 +++++++++++++ .../SessionAnnotationsPagination.cs | 149 +++++++ .../Security/EndpointAccessPolicyCatalog.cs | 10 + .../Deletion/DeletionManifestBuilder.cs | 23 +- .../Offboarding/TenantOffboardingHandler.cs | 4 + .../src/__tests__/tools-catalog-order.test.ts | 18 +- .../autopilot-monitor-mcp/src/tools/admin.ts | 85 ++++ .../src/tools/sessions.ts | 12 +- .../AutopilotMonitor.Shared/Constants.cs | 12 + .../ISessionAnnotationRepository.cs | 47 +++ .../Models/SessionAnnotation.cs | 72 ++++ .../app/privacy/page.tsx | 3 +- .../components/SessionAnnotationsCard.tsx | 279 +++++++++++++ .../__tests__/sessionAnnotationLogic.test.ts | 116 ++++++ .../components/sessionAnnotationLogic.ts | 117 ++++++ .../app/sessions/page.tsx | 17 +- src/Web/autopilot-monitor-web/lib/api.ts | 7 + .../lib/sidebarIcons.tsx | 8 + 29 files changed, 2366 insertions(+), 21 deletions(-) create mode 100644 docs/backend/session-annotations.md create mode 100644 src/Backend/AutopilotMonitor.Functions.Tests/SessionAnnotationFunctionsTests.cs create mode 100644 src/Backend/AutopilotMonitor.Functions.Tests/TableSessionAnnotationRepositoryTests.cs create mode 100644 src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableSessionAnnotationRepository.cs create mode 100644 src/Backend/AutopilotMonitor.Functions/Functions/Annotations/GetSessionAnnotationsFunction.cs create mode 100644 src/Backend/AutopilotMonitor.Functions/Functions/Annotations/ListSessionAnnotationsFunction.cs create mode 100644 src/Backend/AutopilotMonitor.Functions/Functions/Annotations/UpsertSessionAnnotationFunction.cs create mode 100644 src/Backend/AutopilotMonitor.Functions/Pagination/SessionAnnotationsPagination.cs create mode 100644 src/Shared/AutopilotMonitor.Shared/DataAccess/ISessionAnnotationRepository.cs create mode 100644 src/Shared/AutopilotMonitor.Shared/Models/SessionAnnotation.cs create mode 100644 src/Web/autopilot-monitor-web/app/sessions/components/SessionAnnotationsCard.tsx create mode 100644 src/Web/autopilot-monitor-web/app/sessions/components/__tests__/sessionAnnotationLogic.test.ts create mode 100644 src/Web/autopilot-monitor-web/app/sessions/components/sessionAnnotationLogic.ts diff --git a/docs/backend/session-annotations.md b/docs/backend/session-annotations.md new file mode 100644 index 00000000..0c0e093b --- /dev/null +++ b/docs/backend/session-annotations.md @@ -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`. diff --git a/docs/index.md b/docs/index.md index fb1559fe..5f6e63a2 100644 --- a/docs/index.md +++ b/docs/index.md @@ -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 diff --git a/docs/log.md b/docs/log.md index f1a3aea2..152f3fd7 100644 --- a/docs/log.md +++ b/docs/log.md @@ -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). diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/CriticalTableBackupServiceTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/CriticalTableBackupServiceTests.cs index 73bc54a4..25882bd9 100644 --- a/src/Backend/AutopilotMonitor.Functions.Tests/CriticalTableBackupServiceTests.cs +++ b/src/Backend/AutopilotMonitor.Functions.Tests/CriticalTableBackupServiceTests.cs @@ -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)); } diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/DeletionManifestBuilderTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/DeletionManifestBuilderTests.cs index 60c03118..4e32ebdc 100644 --- a/src/Backend/AutopilotMonitor.Functions.Tests/DeletionManifestBuilderTests.cs +++ b/src/Backend/AutopilotMonitor.Functions.Tests/DeletionManifestBuilderTests.cs @@ -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 _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())) .ReturnsAsync(_sessionInventoryContributions); + foreach (var (lane, row) in _sessionAnnotations) + { + reader.Setup(r => r.GetEntityOrNullAsync(Constants.TableNames.SessionAnnotations, TenantId, $"{SessionId}_{lane}", It.IsAny())) + .ReturnsAsync(row); + } // QueryAsync: per-table seeded list, otherwise empty. reader.Setup(r => r.QueryAsync(It.IsAny(), It.IsAny(), It.IsAny())) diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/SessionAnnotationFunctionsTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/SessionAnnotationFunctionsTests.cs new file mode 100644 index 00000000..1ff4f00d --- /dev/null +++ b/src/Backend/AutopilotMonitor.Functions.Tests/SessionAnnotationFunctionsTests.cs @@ -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; + +/// +/// Pins the session-annotation authorization surface: the per-lane write matrix +/// (), the GA-lane +/// read filter (), 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). +/// +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 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().Object; + } + + private static FunctionContext BuildContext(ClaimsPrincipal? principal, RequestContext requestContext) + { + var services = new ServiceCollection(); + services.AddOptions(); + services.Configure(o => o.Serializer = new JsonObjectSerializer()); + var provider = services.BuildServiceProvider(); + + var items = new Dictionary { ["RequestContext"] = requestContext }; + if (principal != null) + { + items["ClaimsPrincipal"] = principal; + } + + var context = new Mock(); + 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(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 AnnotationRepo = default!; + public List Stored = default!; + } + + private static Harness BuildFunction( + SessionAnnotation? existing = null, List? ruleResults = null) + { + var annotationRepo = new Mock(MockBehavior.Loose); + var stored = new List(); + annotationRepo.Setup(r => r.GetAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(existing); + annotationRepo.Setup(r => r.UpsertAsync(It.IsAny())) + .Callback(stored.Add) + .Returns(Task.CompletedTask); + + var sessionRepo = new Mock(MockBehavior.Loose); + sessionRepo.Setup(r => r.GetSessionAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(new SessionSummary { SessionId = SessionId, TenantId = TenantId }); + sessionRepo.Setup(r => r.FindSessionTenantIdAsync(SessionId)).ReturnsAsync(TenantId); + + var ruleRepo = new Mock(MockBehavior.Loose); + ruleRepo.Setup(r => r.GetRuleResultsAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(ruleResults ?? new List()); + + var maintenanceRepo = new Mock(MockBehavior.Loose); + + return new Harness + { + Function = new UpsertSessionAnnotationFunction( + NullLogger.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 + { + 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); + } +} diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/TableSessionAnnotationRepositoryTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/TableSessionAnnotationRepositoryTests.cs new file mode 100644 index 00000000..895b6109 --- /dev/null +++ b/src/Backend/AutopilotMonitor.Functions.Tests/TableSessionAnnotationRepositoryTests.cs @@ -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; + +/// +/// Round-trip pins for the SessionAnnotations Store/Map pair (memory: +/// feedback_table_storage_serialization — every new field in +/// MUST be exercised in Store+Map so silent drops surface here), plus the RowKey and +/// OData-filter builders. +/// +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 { "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(); + + 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); + } +} diff --git a/src/Backend/AutopilotMonitor.Functions/DataAccess/DataAccessServiceExtensions.cs b/src/Backend/AutopilotMonitor.Functions/DataAccess/DataAccessServiceExtensions.cs index fe59a662..5a4ce905 100644 --- a/src/Backend/AutopilotMonitor.Functions/DataAccess/DataAccessServiceExtensions.cs +++ b/src/Backend/AutopilotMonitor.Functions/DataAccess/DataAccessServiceExtensions.cs @@ -31,6 +31,7 @@ namespace AutopilotMonitor.Functions.DataAccess services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); diff --git a/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableSessionAnnotationRepository.cs b/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableSessionAnnotationRepository.cs new file mode 100644 index 00000000..b6f218b5 --- /dev/null +++ b/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableSessionAnnotationRepository.cs @@ -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 +{ + /// + /// Table-storage implementation of . + /// Backed by the dedicated SessionAnnotations table: + /// PK = tenantId, RK = {sessionId}_{lane}. + /// + 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 _logger; + + public TableSessionAnnotationRepository( + TableStorageService storage, ILogger logger) + { + _tableClient = storage.GetTableClient(Constants.TableNames.SessionAnnotations); + _logger = logger; + } + + public async Task GetAsync(string tenantId, string sessionId, string lane) + { + try + { + var entity = await _tableClient.GetEntityAsync( + 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> 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(); + await foreach (var entity in _tableClient.QueryAsync(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 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(); + 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( + _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 DeserializeRuleIds(string? json) + { + if (string.IsNullOrEmpty(json)) return new List(); + try + { + return JsonSerializer.Deserialize>(json!) ?? new List(); + } + catch (JsonException) + { + return new List(); // 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); + } +} diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Annotations/GetSessionAnnotationsFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Annotations/GetSessionAnnotationsFunction.cs new file mode 100644 index 00000000..e0a1bee9 --- /dev/null +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Annotations/GetSessionAnnotationsFunction.cs @@ -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 +{ + /// + /// Returns the annotations (all lanes visible to the caller) for one session. + /// The globaladmin lane is platform-internal and filtered out for callers + /// without global scope. + /// + public class GetSessionAnnotationsFunction + { + private readonly ILogger _logger; + private readonly ISessionAnnotationRepository _annotationRepo; + private readonly ISessionRepository _sessionRepo; + + public GetSessionAnnotationsFunction( + ILogger logger, + ISessionAnnotationRepository annotationRepo, + ISessionRepository sessionRepo) + { + _logger = logger; + _annotationRepo = annotationRepo; + _sessionRepo = sessionRepo; + } + + [Function("GetSessionAnnotations")] + public async Task 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; + } + } + + /// + /// The globaladmin lane is a platform-internal quality label — callers without + /// global scope (tenant members and delegated admins) never see it. + /// + internal static List FilterLanesForCaller( + List annotations, bool hasGlobalScope) + { + if (hasGlobalScope) return annotations; + return annotations + .Where(a => !string.Equals(a.Lane, AnnotationLanes.GlobalAdmin, StringComparison.OrdinalIgnoreCase)) + .ToList(); + } + } + + /// + /// Explicit camelCase wire shape shared by the annotation endpoints so the JSON + /// contract does not depend on serializer naming configuration. + /// + 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, + }; + } +} diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Annotations/ListSessionAnnotationsFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Annotations/ListSessionAnnotationsFunction.cs new file mode 100644 index 00000000..65873df6 --- /dev/null +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Annotations/ListSessionAnnotationsFunction.cs @@ -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 +{ + /// + /// Cross-tenant evaluation stream over all annotation lanes (the flywheel read: + /// verdicts per rule, false-positive rates, labeled sessions). Platform scope only. + /// + public class ListSessionAnnotationsFunction + { + private readonly ILogger _logger; + private readonly ISessionAnnotationRepository _annotationRepo; + + public ListSessionAnnotationsFunction( + ILogger logger, + ISessionAnnotationRepository annotationRepo) + { + _logger = logger; + _annotationRepo = annotationRepo; + } + + [Function("ListSessionAnnotations")] + public async Task 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; + } + } + } +} diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Annotations/UpsertSessionAnnotationFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Annotations/UpsertSessionAnnotationFunction.cs new file mode 100644 index 00000000..0b2effc2 --- /dev/null +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Annotations/UpsertSessionAnnotationFunction.cs @@ -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 +{ + /// + /// Upserts (or clears) the annotation for one session + lane. + /// Policy tier is TenantAdminOrOperator; the per-lane write matrix is re-gated here + /// via (catalog multi-kind rule). Author fields + /// are stamped from the JWT — a body-supplied author never wins. + /// + public class UpsertSessionAnnotationFunction + { + private readonly ILogger _logger; + private readonly ISessionAnnotationRepository _annotationRepo; + private readonly ISessionRepository _sessionRepo; + private readonly IRuleRepository _ruleRepo; + private readonly IMaintenanceRepository _maintenanceRepo; + + public UpsertSessionAnnotationFunction( + ILogger logger, + ISessionAnnotationRepository annotationRepo, + ISessionRepository sessionRepo, + IRuleRepository ruleRepo, + IMaintenanceRepository maintenanceRepo) + { + _logger = logger; + _annotationRepo = annotationRepo; + _sessionRepo = sessionRepo; + _ruleRepo = ruleRepo; + _maintenanceRepo = maintenanceRepo; + } + + [Function("UpsertSessionAnnotation")] + public async Task 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; + } + } + + /// + /// 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) + /// + 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, + }; + } + + /// + /// 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. + /// + private async Task> 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(); + } + } + + // 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 + { + { "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 BadRequestAsync(HttpRequestData req, string message) + { + var response = req.CreateResponse(HttpStatusCode.BadRequest); + await response.WriteAsJsonAsync(new { success = false, message }); + return response; + } + } +} diff --git a/src/Backend/AutopilotMonitor.Functions/Pagination/SessionAnnotationsPagination.cs b/src/Backend/AutopilotMonitor.Functions/Pagination/SessionAnnotationsPagination.cs new file mode 100644 index 00000000..61fc6bfd --- /dev/null +++ b/src/Backend/AutopilotMonitor.Functions/Pagination/SessionAnnotationsPagination.cs @@ -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 +{ + /// + /// Pure helpers for the GET /api/global/session-annotations pagination + /// surface. GA-only at the policy layer; every optional filter is a server-side + /// filter, 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). + /// + 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("scope", "session-annotations"), + new KeyValuePair("tenantId", callerTenantId), + new KeyValuePair("filterTenantId", filterTenantId), + new KeyValuePair("lane", lane), + new KeyValuePair("verdict", verdict), + new KeyValuePair("ruleId", ruleId), + new KeyValuePair("dateFrom", dateFrom?.ToString("o", CultureInfo.InvariantCulture)), + new KeyValuePair("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; + } +} diff --git a/src/Backend/AutopilotMonitor.Functions/Security/EndpointAccessPolicyCatalog.cs b/src/Backend/AutopilotMonitor.Functions/Security/EndpointAccessPolicyCatalog.cs index d3db9e79..6190fba7 100644 --- a/src/Backend/AutopilotMonitor.Functions/Security/EndpointAccessPolicyCatalog.cs +++ b/src/Backend/AutopilotMonitor.Functions/Security/EndpointAccessPolicyCatalog.cs @@ -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 diff --git a/src/Backend/AutopilotMonitor.Functions/Services/Deletion/DeletionManifestBuilder.cs b/src/Backend/AutopilotMonitor.Functions/Services/Deletion/DeletionManifestBuilder.cs index 30aab61f..bbdefcea 100644 --- a/src/Backend/AutopilotMonitor.Functions/Services/Deletion/DeletionManifestBuilder.cs +++ b/src/Backend/AutopilotMonitor.Functions/Services/Deletion/DeletionManifestBuilder.cs @@ -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; } diff --git a/src/Backend/AutopilotMonitor.Functions/Services/Offboarding/TenantOffboardingHandler.cs b/src/Backend/AutopilotMonitor.Functions/Services/Offboarding/TenantOffboardingHandler.cs index af7022a6..85be5be1 100644 --- a/src/Backend/AutopilotMonitor.Functions/Services/Offboarding/TenantOffboardingHandler.cs +++ b/src/Backend/AutopilotMonitor.Functions/Services/Offboarding/TenantOffboardingHandler.cs @@ -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). diff --git a/src/McpServer/autopilot-monitor-mcp/src/__tests__/tools-catalog-order.test.ts b/src/McpServer/autopilot-monitor-mcp/src/__tests__/tools-catalog-order.test.ts index bed3188c..b42892e5 100644 --- a/src/McpServer/autopilot-monitor-mcp/src/__tests__/tools-catalog-order.test.ts +++ b/src/McpServer/autopilot-monitor-mcp/src/__tests__/tools-catalog-order.test.ts @@ -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([]); + }); }); diff --git a/src/McpServer/autopilot-monitor-mcp/src/tools/admin.ts b/src/McpServer/autopilot-monitor-mcp/src/tools/admin.ts index f09c4d8d..9ee7e7c1 100644 --- a/src/McpServer/autopilot-monitor-mcp/src/tools/admin.ts +++ b/src/McpServer/autopilot-monitor-mcp/src/tools/admin.ts @@ -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 diff --git a/src/McpServer/autopilot-monitor-mcp/src/tools/sessions.ts b/src/McpServer/autopilot-monitor-mcp/src/tools/sessions.ts index 88047ecd..280550aa 100644 --- a/src/McpServer/autopilot-monitor-mcp/src/tools/sessions.ts +++ b/src/McpServer/autopilot-monitor-mcp/src/tools/sessions.ts @@ -468,10 +468,13 @@ export function registerSessionTools(server: McpServer, ga: boolean, delegated: const q = buildQuery({ tenantId } as Record); 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>, apiFetch(`/api/sessions/${sessionId}/events${q}`, fetchOpts) as Promise<{ events?: Array>; count?: number }>, apiFetch(`/api/sessions/${sessionId}/analysis${q}`, fetchOpts).catch(() => null) as Promise | 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> } | null>, ]); const s = (sessionData.session ?? sessionData) as Record; @@ -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, diff --git a/src/Shared/AutopilotMonitor.Shared/Constants.cs b/src/Shared/AutopilotMonitor.Shared/Constants.cs index 8f3a9a8c..22bcafd4 100644 --- a/src/Shared/AutopilotMonitor.Shared/Constants.cs +++ b/src/Shared/AutopilotMonitor.Shared/Constants.cs @@ -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"; + /// /// Returns all table names for initialization /// @@ -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, diff --git a/src/Shared/AutopilotMonitor.Shared/DataAccess/ISessionAnnotationRepository.cs b/src/Shared/AutopilotMonitor.Shared/DataAccess/ISessionAnnotationRepository.cs new file mode 100644 index 00000000..ada34c71 --- /dev/null +++ b/src/Shared/AutopilotMonitor.Shared/DataAccess/ISessionAnnotationRepository.cs @@ -0,0 +1,47 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using AutopilotMonitor.Shared.Models; + +namespace AutopilotMonitor.Shared.DataAccess +{ + /// + /// Storage for rows. Backed by the dedicated + /// SessionAnnotations table (PK = tenantId, RK = {sessionId}_{lane}). + /// 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. + /// + public interface ISessionAnnotationRepository + { + /// Returns the annotation for one session + lane, or null. + Task GetAsync(string tenantId, string sessionId, string lane); + + /// Returns all lanes present for one session (0–3 rows). + Task> GetForSessionAsync(string tenantId, string sessionId); + + /// Creates or replaces the row for the annotation's session + lane. + Task UpsertAsync(SessionAnnotation annotation); + + /// Deletes the row for one session + lane. Missing row is a no-op. + Task DeleteAsync(string tenantId, string sessionId, string lane); + + /// + /// Global evaluation query (cross-tenant filtered table scan; annotation volume is + /// human-entered and small). All filters optional. is + /// matched client-side against , so this + /// method back-fills short pages by looping the Azure continuation until + /// matches accumulate or the scan is exhausted + /// (bounded round-trips) — a filtered-out row never consumes page budget. + /// + /// The page items plus the raw Azure continuation token for the next page (null when done). + Task<(List Items, string? NextRawToken)> QueryPageAsync( + string? tenantId, + string? lane, + string? verdict, + string? ruleId, + DateTime? dateFrom, + DateTime? dateTo, + int pageSize, + string? continuation); + } +} diff --git a/src/Shared/AutopilotMonitor.Shared/Models/SessionAnnotation.cs b/src/Shared/AutopilotMonitor.Shared/Models/SessionAnnotation.cs new file mode 100644 index 00000000..67a0ae92 --- /dev/null +++ b/src/Shared/AutopilotMonitor.Shared/Models/SessionAnnotation.cs @@ -0,0 +1,72 @@ +using System; +using System.Collections.Generic; + +namespace AutopilotMonitor.Shared.Models +{ + /// + /// Human-entered per-session verdict + free-text note, role-separated into lanes + /// (one row per session + lane, editable in place). Stored in the + /// SessionAnnotations table: PK = tenantId, RK = {sessionId}_{lane}. + /// The globaladmin lane is platform-internal and never returned to tenant + /// callers. Author fields are stamped server-side from the JWT, never from the body. + /// + public class SessionAnnotation + { + /// Maximum length of (matches the feedback comment cap). + public const int MaxNoteLength = 4096; + + public string TenantId { get; set; } = string.Empty; + public string SessionId { get; set; } = string.Empty; + + /// One of . + public string Lane { get; set; } = string.Empty; + + /// One of , or null (note-only annotation). + public string? Verdict { get; set; } + + /// Free text, ≤ , or null (verdict-only annotation). + public string? Note { get; set; } + + /// UPN of the last editor. Server-stamped. + public string AuthorUpn { get; set; } = string.Empty; + + /// Display name of the last editor. Server-stamped. + public string AuthorDisplayName { get; set; } = string.Empty; + + /// UPN of the first writer. Immutable across edits. + public string CreatedByUpn { get; set; } = string.Empty; + + public DateTime CreatedAtUtc { get; set; } + public DateTime UpdatedAtUtc { get; set; } + + /// + /// Snapshot of the rule ids that had fired for the session at write time + /// (denormalized so rule-quality evaluation needs no join against RuleResults). + /// + public List RuleIds { get; set; } = new(); + } + + /// Lane discriminators — the RowKey suffix and the write-permission axis. + 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 }; + } + + /// Structured verdict vocabulary. A verdict is always optional. + 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, + }; + } +} diff --git a/src/Web/autopilot-monitor-web/app/privacy/page.tsx b/src/Web/autopilot-monitor-web/app/privacy/page.tsx index 51d74f9d..a7c3ef51 100644 --- a/src/Web/autopilot-monitor-web/app/privacy/page.tsx +++ b/src/Web/autopilot-monitor-web/app/privacy/page.tsx @@ -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() {
  • Sign-in identity from Microsoft Entra ID — user principal name, display name, and tenant ID
  • Audit records of administrative actions, including who performed them and when
  • +
  • Content you choose to enter about a session — report comments and annotations (a verdict on the analysis plus notes) — stored with your tenant's data and attributed to the author
  • Operational request telemetry used to run and support the service
  • A contact address for your tenant, if one is provided
diff --git a/src/Web/autopilot-monitor-web/app/sessions/components/SessionAnnotationsCard.tsx b/src/Web/autopilot-monitor-web/app/sessions/components/SessionAnnotationsCard.tsx new file mode 100644 index 00000000..728e58e2 --- /dev/null +++ b/src/Web/autopilot-monitor-web/app/sessions/components/SessionAnnotationsCard.tsx @@ -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 = { + 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; +}) { + const [annotations, setAnnotations] = useState>>({}); + const [loaded, setLoaded] = useState(false); + const [edit, setEdit] = useState>>({}); + + 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> = {}; + for (const a of json.annotations ?? []) byLane[a.lane] = a; + setAnnotations(byLane); + const seeded: Partial> = {}; + 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) => { + 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 ( +
+
+

Annotations

+
+

+ Human assessment of this enrollment's analysis — confirmed root causes and + corrections feed rule quality. +

+ +
+ {lanes.map((lane) => { + const annotation = annotations[lane]; + const writable = canWriteLane(lane, user, isCrossTenantView); + const state = laneEdit(lane); + + if (!writable && !hasContent(annotation)) { + return null; + } + + return ( +
+
+ {LANE_LABELS[lane]} + {annotation?.verdict != null && !writable && ( + + {VERDICT_LABELS[annotation.verdict as AnnotationVerdict] ?? annotation.verdict} + + )} + {annotation?.authorDisplayName && ( + + {annotation.authorDisplayName} + {annotation.updatedAtUtc ? ` · ${new Date(annotation.updatedAtUtc).toLocaleString()}` : ""} + + )} +
+ + {writable ? ( + <> +
+ {ANNOTATION_VERDICTS.map((verdict) => { + const selected = state.verdict === verdict; + return ( + + ); + })} +
+