Compare commits

...

31 Commits

Author SHA1 Message Date
Oliver Kieselbach c770bbffcf build: keep NuGet audit advisories as warnings in the warning freeze
A CVE published against an already-referenced package must not block an
unrelated hotfix deploy; advisories are handled by bumping the package.
WarningsNotAsErrors NU1901-NU1904 - everything else stays fatal.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-13 09:49:27 +02:00
Oliver Kieselbach 3fbdc8ef62 build: freeze .NET builds at zero warnings (warnings-as-errors)
Repo-wide Directory.Build.props: TreatWarningsAsErrors +
MSBuildTreatWarningsAsErrors. Applies to compiler, MSBuild and NuGet
restore warnings including audit advisories (NU1901-NU1904) - a newly
published CVE fails the build until addressed (fail-closed).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-13 09:44:40 +02:00
Oliver Kieselbach 35cea4fe64 build: clear all remaining .NET build warnings to zero
- CmTraceLogParser: annotate TryParseLine (string? line, [NotNullWhen(true)]
  out entry), init string properties (CS8618/CS8625/CS8604)
- Shared: internal NotNullWhen polyfill for netstandard2.0; multi-target
  netstandard2.0;net10.0 so net10 consumers use the BCL attribute and the
  polyfill cannot collide via InternalsVisibleTo (CS0433)
- AgentEndpointMigrationRules.TryNormalizeTarget: now carries [NotNullWhen(true)]
- Functions.csproj: drop DataProtection/Cryptography.Xml security pins made
  redundant by .NET 10 framework-package pruning (NU1510)
- Functions.Tests.csproj: add Microsoft.AspNetCore.App FrameworkReference so
  pruning applies there too and the pins stay unnecessary (NU1903/NU1904)
- Agent tests: nullable test-helper param, Assert.NotNull before dereference

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-13 09:38:41 +02:00
Oliver Kieselbach ade6c4948f annotations: tenant switcher on the overview page + GA-lane guard on the global list
/annotations now carries the standard cross-tenant scope (GA/Reader: "All
tenants" aggregate + per-tenant drill-down via GET global/session-annotations,
delegated: managed subset), with a tenant column and tenant-qualified session
deep links. The global list endpoint now derives excludeGlobalAdminLane from
HasGlobalScope so delegated-rescue callers never receive the platform-internal
globaladmin lane (endpoint-test-pinned).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-13 09:27:31 +02:00
Oliver Kieselbach 83da522dd7 ci: freeze the web lint warning cap at zero (batch 5)
The 99-warning legacy backlog from the React 19 / eslint-plugin-react-hooks
v6 upgrade is fully burned down — CI now fails on any new warning. The
compiler rules stay demoted to "warn" so local dev output remains readable;
the eslint config comments now point to the established fix patterns
instead of the retired ratchet.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-12 19:23:44 +02:00
Oliver Kieselbach c15394df87 web: refactor context/sidebar/theme effect-state sync, last lint sites to zero (batch 3)
- AuthContext: delete the SSR isLoading effect — effects never run on the
  server, the guard was unreachable dead code.
- TenantContext: mirror user.tenantId via adjust-during-render; the value
  is still retained while auth resolves and cleared on settled sign-out.
- Global/Tenant notification contexts: scope-masked empty list derived in
  the provider value instead of effect-clearing; loading flag moved into
  the canonical async wrapper.
- New shared hooks/useMediaQuery (useSyncExternalStore, configurable
  server snapshot) — consumed by GlobalSidebar (isDesktop) and
  McpTerminalDemo (prefers-reduced-motion, finished transcript derived).
- GlobalSidebar: route-mode active section derived from pathname;
  active-group and pathname auto-expansion as adjust-during-render, state
  now holds user toggles only.
- ThemeContext: theme read via useSyncExternalStore backed by
  lib/themeStore (manual preference beats OS preference in the snapshot;
  same-tab writes notify through the store's listener list).
- TipOfTheDay: random tip via useSyncExternalStore with module-memoized
  pick (build-time Math.random would freeze one tip and mismatch on
  hydration); re-rolls per mount via cleanup.
- Navbar: previewMode block removed entirely — nothing in the codebase
  consumes the flag anymore, it only persisted itself.
- activation page: ?demo flag via useSyncExternalStore, tri-state null
  sentinel dropped.

Lint is at zero react-hooks warnings repo-wide.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-12 19:17:55 +02:00
Oliver Kieselbach 810a6f1f2f web: derive instead of effect-sync across reset/clamp/seed lint sites (batch 2)
Structural fixes for the set-state-in-effect sites that were genuine
state synchronization, using the React-documented patterns:

- adjust-during-render (compare-prev) for reset-on-dep-change: dashboard
  page reset + tenant-filter clear, InstallsTab/MappedSoftwareTab page
  resets, TenantManagementSection URL seeding + page reset,
  OpsAlertRulesSection draft re-seeding, SessionReportsSection deep-link
  auto-open, ReportSessionModal result reset.
- clamp/derive at read: dashboard effectivePage (also removes the
  documented handleNextPage race), backups detail effectiveTableName,
  useBlockDevice scope-masked set, InstallProgress and
  useProgressDerivedData live timers (startedAt-tagged tick state),
  sessions timeBreakdown read guard.
- key-based reset: SessionReportsSection note editor, DeleteConfirmModal
  content remount.
- inverted state: sessions page stores collapsedPhases so new phases are
  expanded by default with no sync effect; hash deep-link expandAll moved
  into its timer callback.
- tagged result state: useScriptDisplayNames stores {key, map} and serves
  the empty map on key mismatch — same staleness guarantee, no
  effect-phase clearing.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-12 19:17:36 +02:00
Oliver Kieselbach 019ac3959e web: clear the three preserve-manual-memoization warnings (batch 4)
- useProgressDerivedData: hoist the currentDownload computation into a
  module-level pure helper — the in-loop Map mutation and early return
  kept the compiler from memoizing the body inline.
- DeviceDetailsCard: derive bootTime from events inside the uptime memo
  (deps [events]) — the outer const is a live alias into event data that
  the compiler's alias analysis treats as maybe-mutated.
- useSessionEvents: route the queued follow-up re-invocation through a
  ref — a memoized callback that calls itself directly cannot be
  memoized. The now-traceable mount effect gets the standard wrapper.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-12 18:56:07 +02:00
Oliver Kieselbach 9236aba46e web: canonical inner-async wrappers for all fetch-effect lint sites (batch 1)
Convert every useEffect that directly invokes a state-setting fetch
callback to the canonical inner-async shape (const run = async () => {...};
void run();) — the react-hooks/set-state-in-effect rule traces direct
calls but accepts effect-scoped async functions. Semantic no-op: an async
body runs synchronously until its first await, so execution order is
byte-identical. Leading loading/error/data resets that are part of the
fetch flow move to the top of run in unchanged order.

52 files, ~60 sites. Lint 99 -> 36 warnings; remaining sites are
reset/derive effects and sensitive context/sidebar refactors (next
batches).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-12 18:55:49 +02:00
Oliver Kieselbach fda52666d6 web: clear set-state-in-effect lint warning on the annotations page
The annotations overview pushed the ESLint warning count to 100, one over
the frozen --max-warnings 99 cap, failing CI since 66d25f57's run. The
react-hooks/set-state-in-effect rule flags any direct effect call into a
state-setting useCallback, even with every setState behind an await.

Restructure instead of suppressing: loading/error begin-state moves from
fetchPage into the interaction sites (filter selects, load-more; mount is
covered by the initial state), and the effect invokes fetchPage through an
inner async wrapper so no setState is synchronously reachable. Lint is
back to exactly 99.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-12 17:19:52 +02:00
Oliver Kieselbach dcec6a1863 web: friendly empty/error states on the annotations overview page
A 404 (backend without the route yet, deploy skew) now renders the
regular empty state instead of an error; genuine load failures show a
calm non-technical retry hint in amber instead of raw statusText in red.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-12 16:42:29 +02:00
Oliver Kieselbach 041626494e ci: bump actions/cache v4 -> v6 (Node 24 runtime)
Node 20 actions are deprecated on GitHub runners; actions/cache@v4 was
the last remaining action targeting it (MCP server tests job warning).
v6 runs on node24 like the rest of the workflow actions.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-12 16:41:10 +02:00
Oliver Kieselbach fd32a90d57 web: label the GA annotation lane as internal, with a visibility badge
"Autopilot Monitor Team" read like a vendor-to-customer message channel.
The lane only ever renders for platform-scope users, so it now says
"Internal (Autopilot Monitor)" and carries a "never visible to the
tenant" badge on the session card.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-12 16:39:25 +02:00
Oliver Kieselbach 90b8243894 annotations: move tenant list route under the /api/sessions cert-exclusion prefix
/api/session-annotations sat outside every clientCertExclusionPaths
prefix, so browsers got the client-cert picker. Rename-over-exclusion
per the diag-files precedent (the Azure list is portal-only config and
dies on app recreation). Three segments (sessions/annotations/list) on
purpose: a two-segment literal would be eaten by sessions/{sessionId}.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-12 16:35:54 +02:00
Oliver Kieselbach 66d25f57db Cloud-PC-aware autopilot_profile_missing: Info instead of Warning on W365
On Windows 365 Cloud PCs a missing Autopilot profile (ProfileAvailable=0)
is the expected state - Cloud PCs are provisioned by the Windows 365
service, not by Autopilot - so the warning shown for physical devices is
misleading there.

- Agent: emit autopilot_profile_missing as Info with a Cloud-PC-specific
  message when CloudPcDetector matches; payload now carries isCloudPc.
  Evidence collection (ZTD verdict, registry, endpoint probe) unchanged.
- Web: DeviceDetailsCard renders a neutral info note instead of the amber
  warning for Cloud PC sessions (session.isCloudPc, sticky, covers
  existing sessions; or the new event marker).
- MCP: resource-catalog documents the new isCloudPc data field.
- Docs: Cloud PC exception noted in agent/autopilot-ztd-diagnostics.md.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-12 16:13:34 +02:00
Oliver Kieselbach 84a0424789 annotations: tenant-scoped overview page listing judged sessions
New GET /api/session-annotations (MemberRead + QueryParam, tenant always
the middleware-validated target) backing the portal /annotations page in
the Monitoring sidebar group (deliberately no new Insights group). For
callers without global scope the repository excludes the globaladmin
lane server-side (Lane ne clause), so hidden rows never consume page
budget and an explicit lane filter self-contradicts to empty. Rows
deep-link to the session annotation section. Verdict pill styling moved
to the shared logic module.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-12 16:13:04 +02:00
Oliver Kieselbach 596788cc26 fix dark mode 2026-08-12 14:32:24 +02:00
Oliver Kieselbach aec2d249e1 Merge branch 'main' of https://github.com/okieselbach/Autopilot-Monitor 2026-08-12 14:23:13 +02:00
Oliver Kieselbach a63f126b88 update dark theme script executions 2026-08-12 14:23:06 +02:00
dependabot[bot] d94a91780c chore(deps): bump the web group across 1 directory with 5 updates (#129)
Bumps the web group with 5 updates in the /src/Web/autopilot-monitor-web directory:

| Package | From | To |
| --- | --- | --- |
| [@microsoft/signalr](https://github.com/dotnet/aspnetcore) | `10.0.0` | `10.0.11` |
| [@types/leaflet](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/leaflet) | `1.9.21` | `1.9.22` |
| [next](https://github.com/vercel/next.js) | `16.2.12` | `16.3.0` |
| [@next/bundle-analyzer](https://github.com/vercel/next.js/tree/HEAD/packages/next-bundle-analyzer) | `16.2.12` | `16.3.0` |
| [eslint-config-next](https://github.com/vercel/next.js/tree/HEAD/packages/eslint-config-next) | `16.2.12` | `16.3.0` |



Updates `@microsoft/signalr` from 10.0.0 to 10.0.11
- [Release notes](https://github.com/dotnet/aspnetcore/releases)
- [Changelog](https://github.com/dotnet/aspnetcore/blob/main/docs/ReleasePlanning.md)
- [Commits](https://github.com/dotnet/aspnetcore/commits)

Updates `@types/leaflet` from 1.9.21 to 1.9.22
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/leaflet)

Updates `next` from 16.2.12 to 16.3.0
- [Release notes](https://github.com/vercel/next.js/releases)
- [Commits](https://github.com/vercel/next.js/compare/v16.2.12...v16.3.0)

Updates `@next/bundle-analyzer` from 16.2.12 to 16.3.0
- [Release notes](https://github.com/vercel/next.js/releases)
- [Commits](https://github.com/vercel/next.js/commits/v16.3.0/packages/next-bundle-analyzer)

Updates `eslint-config-next` from 16.2.12 to 16.3.0
- [Release notes](https://github.com/vercel/next.js/releases)
- [Commits](https://github.com/vercel/next.js/commits/v16.3.0/packages/eslint-config-next)

---
updated-dependencies:
- dependency-name: "@microsoft/signalr"
  dependency-version: 10.0.11
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: web
- dependency-name: "@next/bundle-analyzer"
  dependency-version: 16.3.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: web
- dependency-name: "@types/leaflet"
  dependency-version: 1.9.22
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: web
- dependency-name: eslint-config-next
  dependency-version: 16.3.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: web
- dependency-name: next
  dependency-version: 16.3.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: web
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-11 16:18:11 +02:00
Oliver Kieselbach 0b83fe2390 Merge branch 'main' of https://github.com/okieselbach/Autopilot-Monitor 2026-08-11 16:09:00 +02:00
Oliver Kieselbach 75c0c60717 web: use absolute destination for sign-in redirect in global error boundary
Next 16.3's new @next/next/no-location-assign-relative-destination rule
flags relative URLs assigned to window.location, pushing the lint warning
count past the CI cap of 99.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-11 15:09:13 +02:00
Oliver Kieselbach 29e7aa675a web: native title tooltips explaining each annotation verdict
Same wording as the customer docs; plain browser tooltip, no cursor
change. Applied to the verdict buttons and the read-only pill.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-11 15:06:50 +02:00
dependabot[bot] c28d64fe27 chore(deps-dev): bump tsx (#128)
Bumps the mcp group in /src/McpServer/autopilot-monitor-mcp with 1 update: [tsx](https://github.com/privatenumber/tsx).


Updates `tsx` from 4.23.1 to 4.23.11
- [Release notes](https://github.com/privatenumber/tsx/releases)
- [Changelog](https://github.com/privatenumber/tsx/blob/master/release.config.cjs)
- [Commits](https://github.com/privatenumber/tsx/compare/v4.23.1...v4.23.11)

---
updated-dependencies:
- dependency-name: tsx
  dependency-version: 4.23.11
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: mcp
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-11 14:45:08 +02:00
Oliver Kieselbach b2723168fd reports: snapshot session annotations into the report ZIP
SubmitReportAsync adds annotations.json (all lanes) to the package and
annotationCount to report-metadata.json. All lanes are safe: report
blobs are readable only through the GA-only session-reports routes.
Fail-soft like the tenant-context snapshot; the copy outlives the
session=s retention/delete.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-11 14:38:33 +02:00
Oliver Kieselbach d05d8ef302 web: annotations card collapsed by default with verdict summary header
The verdict comes at the end of a diagnosis, so the card must not take
space from the details/analysis work above it. Clickable chevron header
per section convention; count + verdict pills stay visible collapsed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-11 14:33:40 +02:00
Oliver Kieselbach 2c48968ec6 session annotations: role-separated human verdicts on sessions (flywheel labels)
New SessionAnnotations table (PK=tenantId, RK={sessionId}_{lane}, lanes
operator/tenantadmin/globaladmin) storing structured verdicts + notes with
server-stamped authorship and a fired-rule-id snapshot for join-free
rule-quality evaluation. Per-lane write matrix re-gated in-function with
own-tenant binding; the globaladmin lane is platform-internal and filtered
from tenant callers. Portal: SessionAnnotationsCard on session detail.
MCP: list_session_annotations (ga) + annotate_session (strictGa, GA lane
only) + annotations key in get_session_summary. Lifecycle: cascade steps
17-19, offboarding wipe, critical-backup member. Privacy page gains the
customer-entered-content line.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-11 13:30:44 +02:00
Oliver Kieselbach 2f8ad82b0a gather-rules: mirror hard blocks in portal/MCP, stamp author on PUT-upsert; agent: bootstrap retry tests
Codex follow-ups to 1ebc2675:

- guardrails.json now mirrors blockedCommandPatterns + maxCommandLength and
  carries C:\Windows\System32\config in blockedFilePrefixes; portal
  (guardValidation.ts) and MCP (rule-validation.ts) enforce them in agent
  order (before the unrestricted-mode early-return), so hard-blocked commands
  no longer show "Allowed" in unrestricted tenants. GuardrailsParityTests
  pins the JSON mirror to the C# constants.
- gather-rule schema: collectorType enum gains json/xml (agent + portal
  supported both; MCP validate_rule falsely rejected them); MCP validates
  json/xml targets with file semantics and rejects activePhases +
  activeFromPhase set together (backend 400 parity); authoring guide updated.
- Author anti-spoof: tenant + global UpdateRule stamp the caller's JWT
  display name before the service call, closing the PUT-upsert hole where a
  non-existing ruleId stored the payload author; true updates still preserve
  the original. GatherRulesFunctionAuthorEndpointTests drives the real
  endpoints (first HttpRequestData-level test) proving body authors never win.
- AgentBootstrap.RetrySelfUpdateAfterEnrollment: internal + UpdateInvokerOverride
  test seam (RequestGracefulShutdown idiom), LastVersionCheckSucceeded setter
  internal; AgentBootstrapSelfUpdateRetryTests pin the guard, the relaxed
  await_enrollment timeouts, and the swallow-everything exception contract
  with save/restore of the static state.

Functions 3958, Agent-Core 2200, Web 670, MCP 543 tests green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-11 12:29:05 +02:00
Oliver Kieselbach 4b74995e79 shared: drop Private Preview wording from ContactEmail seeding comment
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-10 20:34:16 +02:00
Oliver Kieselbach 5d8d9e8d40 web: show tenant contact email in config report tenant status
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-10 17:58:47 +02:00
Oliver Kieselbach 25bd2c175e web: bump nanoid to 3.3.18 and js-yaml to 4.3.1 (Dependabot high alerts)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-10 14:00:59 +02:00
156 changed files with 5092 additions and 944 deletions
+5 -6
View File
@@ -77,12 +77,11 @@ jobs:
- name: Typecheck
run: npx tsc --noEmit
# Errors fail outright. Warnings are ratcheted: the cap is the current
# count of accepted warnings (React-Compiler hooks-rule findings demoted
# in eslint.config.mjs + exhaustive-deps) — when you fix warnings, lower
# the cap to match; never raise it.
# Errors fail outright, and so does ANY warning: the codebase is
# warning-clean and must stay that way — fix new findings, never
# raise the cap.
- name: Lint
run: npm run lint -- --max-warnings 99
run: npm run lint -- --max-warnings 0
- name: Tests
run: npm test
@@ -114,7 +113,7 @@ jobs:
# (run 31252869254). Persist the model across runs instead; HF_CACHE_DIR
# is honored by vector-search-provider.ts.
- name: Cache embedding model
uses: actions/cache@v4
uses: actions/cache@v6
with:
path: ${{ github.workspace }}/.hf-cache
key: hf-model-Xenova-all-MiniLM-L6-v2
+12
View File
@@ -0,0 +1,12 @@
<Project>
<!-- Warning freeze (2026-08-13, backlog cleared to zero): every .NET build in this repo
must stay warning-free. Compiler, MSBuild and NuGet restore warnings are all fatal.
Exception: NuGet audit advisories (NU1901-NU1904) stay warnings — a CVE published
against an already-referenced package must not block an unrelated hotfix deploy;
advisories are handled by bumping the package, not by failing the build. -->
<PropertyGroup>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<MSBuildTreatWarningsAsErrors>true</MSBuildTreatWarningsAsErrors>
<WarningsNotAsErrors>NU1901;NU1902;NU1903;NU1904</WarningsNotAsErrors>
</PropertyGroup>
</Project>
+8
View File
@@ -21,6 +21,14 @@ three of them to turn the `autopilot_profile_missing` warning from a guess into
evidence-backed verdict, and the backend rule engine uses the error-code map for
known-issue enrichment.
Windows 365 Cloud PC exception: on Cloud PCs (`CloudPcDetector.DetectIsCloudPc()`)
`ProfileAvailable=0` is the expected state — Cloud PCs are provisioned by the Windows 365
service, not by Autopilot. The event is still emitted with the full evidence payload plus
an `isCloudPc` marker, but as severity Info with a "this is expected" message instead of
a Warning (`DeviceInfoCollector.ResolveAutopilotProfileMissingPresentation`). The portal's
Autopilot Profile card mirrors this: a neutral note for Cloud PC sessions
(`session.isCloudPc` or the event marker), the amber warning otherwise.
Consumers in this repo:
* `ZtdEvidence.cs` (agent) — one-shot event-log query + `Diagnostics\Autopilot` registry
+122
View File
@@ -0,0 +1,122 @@
---
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; delegated ("MSP") readers are rescue-admitted on a managed `?tenantId=` only |
**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). Both list endpoints enforce the same rule server-side via the repository's
`excludeGlobalAdminLane` OData clause — on the global route this matters exactly for the
delegated-rescue path, which reaches it without global scope. Tenant lanes are readable by all
members of that tenant.
**Audit.** Non-GA writes/clears log `LogAuditEntryAsync(..., "SessionAnnotation",
"{sessionId}/{lane}", ...)`; GA writes are skipped (platform-internal labeling must not surface
in the tenant-visible audit log — same convention as GA session-report submissions).
# Lifecycle
* **Per-session cascade delete**: three exact-RK steps (one per lane) in `DeletionManifestBuilder`
(orders 17–19; inventory 20/21, tombstone 22). `ComputePreflightCounts` sums repeated-table
steps so the `sessionAnnotations` count covers all lanes.
* **Tenant offboarding**: `SessionAnnotations` is in the PK=tenantId wipe list — deliberately
wiped, unlike `Feedback` (annotations are customer session data, not product feedback).
* **Backup**: member of `Constants.CriticalBackupTables.All` — hand-labeled data is the most
expensive data on the platform to lose.
* Being in `Constants.TableNames.All` auto-creates the table at startup and exposes it to the
raw `list_tables`/`query_table` tools.
# Surfaces
* **Portal**: `SessionAnnotationsCard` on the session detail page (`#section-annotations`, after
Analysis; registered in BOTH the section div and the `sessionSections` sidebar registry). The
card mirrors the matrix via the pure module `sessionAnnotationLogic.ts` (vitest-pinned) and
uses the explicit-Save pattern; the backend re-gates every save. Collapsed by default with a
count + verdict-pill summary header.
* **Portal overview page** `/annotations` (Monitoring sidebar group — deliberately NO new
"Insights" group; Fleet Health & co. are insights too and a second analytics group would
dilute both): tenant-scoped list backed by `GET sessions/annotations/list`
(`ListTenantSessionAnnotationsFunction`, MemberRead + QueryParam). The path sits under the
`/api/sessions` client-cert exclusion prefix (rename-over-exclusion convention; the Azure
exclusion list is portal-only config) and uses THREE segments because a two-segment literal
would be eaten by the `sessions/{sessionId}` route template. The tenant is always the
middleware-validated `TargetTenantId`, and for callers without global scope the repository
adds a server-side `Lane ne 'globaladmin'` OData clause — hidden rows never consume page
budget, and an explicit lane=globaladmin filter self-contradicts to an empty page. Rows
deep-link to `sessions?id=…#section-annotations`. The page carries the standard tenant
switcher (`useAggregatedAdminScope` + `TenantScopeSelector`): a GA/Reader defaults to the
"All tenants" aggregate (the portal twin of MCP's `list_session_annotations`) with per-tenant
drill-down, a delegated ("MSP") admin gets the managed subset; cross-tenant scopes route to
`GET global/session-annotations`, a tenant column appears, and deep links carry the row's
`tenantId`.
* **MCP read**: `get_session_summary` carries an `annotations` key (4th parallel leg, fail-soft
null); `list_session_annotations` (`ga`-gated) is the evaluation stream with `tenantId / lane /
verdict / ruleId / dateFrom / dateTo` filters and nextLink pagination. The `ruleId` filter is
applied server-side but client-of-Azure (substring on the JSON column), so the backend
back-fills short pages by looping the Azure continuation (bounded rounds) — filtered-out rows
never consume page budget.
* **MCP write**: `annotate_session` (`strictGa`-gated, MUTATING) always PUTs the `globaladmin`
lane — no lane argument by design. This is the frictionless labeling path after a session-debug.
* **Session reports**: `SessionReportService.SubmitReportAsync` snapshots ALL lanes into
`annotations.json` inside the report ZIP (`annotationCount` in report-metadata.json; fail-soft).
Safe because report blobs are readable only through the GA-only session-reports routes, and
valuable because the ZIP outlives the session's retention/delete.
# Citations
* `src/Shared/AutopilotMonitor.Shared/Models/SessionAnnotation.cs` — model, lanes, verdicts.
* `src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableSessionAnnotationRepository.cs` — Store/Map + query back-fill.
* `src/Backend/AutopilotMonitor.Functions/Functions/Annotations/` — the three endpoints.
* `src/Backend/AutopilotMonitor.Functions/Pagination/SessionAnnotationsPagination.cs` — filter-bound continuation tokens.
* `src/Web/autopilot-monitor-web/app/sessions/components/sessionAnnotationLogic.ts` — portal matrix mirror.
* `src/McpServer/autopilot-monitor-mcp/src/tools/admin.ts` — `list_session_annotations`, `annotate_session`.
+1
View File
@@ -42,6 +42,7 @@ https://docs.autopilotmonitor.com (separate repository).
* [Tenant Activation & the Once-Only Welcome Mail](backend/tenant-activation-welcome-mail.md) - How a signup becomes an activated tenant (manual approve or the auto-approve queue), and the write-then-read + conditional-marker arbitration that sends the welcome mail exactly once even though activation and the user-typed notification address race each other.
* [W365 Cloud PC Device Validation](backend/cloudpc-device-validation.md) - Why Cloud PCs can never pass the Autopilot serial lookup, and the cert-CN-bound CloudPc fallback validator: Subject CN (= Intune device id) matched against the tenant's Graph virtualEndpoint/cloudPCs inventory — admits exactly the service-provisioned Cloud PCs, nothing else.
* [ServerActions & On-Demand Diagnostics](backend/server-actions-on-demand-diagnostics.md) - The queue-on-session, deliver-on-ingest action channel, the TenantAdminOrOperator tier with in-function per-type re-gating, the portal's Collect Logs flow (quick-config orders rotate_config before request_diagnostics), and the hosted-prefix sweep in the offboarding cascade.
* [Session Annotations](backend/session-annotations.md) - Role-separated human verdicts on sessions (operator / tenantadmin / globaladmin lanes, one row each): the per-lane write matrix with own-tenant binding, the platform-internal GA lane, the fired-rule-id snapshot that makes annotations rule-quality labels, and the portal card + MCP list/annotate surfaces.
# MCP Server
+16
View File
@@ -1,5 +1,21 @@
# Log
## 2026-08-13
* **Update**: `backend/session-annotations.md` — the `/annotations` portal overview page now carries the standard tenant switcher (GA/Reader: "All tenants" aggregate + per-tenant drill-down via `GET global/session-annotations`; delegated: managed subset), with a tenant column and tenant-qualified session deep links. The global list endpoint now derives `excludeGlobalAdminLane` from `HasGlobalScope`, so delegated-rescue callers on their managed `?tenantId=` path never receive the platform-internal `globaladmin` lane (endpoint-test-pinned).
## 2026-08-12
* **Update**: `agent/autopilot-ztd-diagnostics.md` — Windows 365 Cloud PC exception documented: on Cloud PCs `ProfileAvailable=0` is expected (provisioned by the Windows 365 service, not Autopilot), so `autopilot_profile_missing` is emitted as Info with a Cloud-PC-specific message and an `isCloudPc` payload marker (`ResolveAutopilotProfileMissingPresentation`); the portal's Autopilot Profile card renders a neutral note instead of the amber warning for Cloud PC sessions (`session.isCloudPc` or the event marker).
## 2026-08-11 (later)
* **Creation**: Added `backend/session-annotations.md` — session annotations: the new `SessionAnnotations` table (PK=tenantId, RK=`{sessionId}_{lane}`, lanes operator/tenantadmin/globaladmin) storing structured verdicts (`root_cause_confirmed | analysis_wrong | different_problem | inconclusive`) + notes with server-stamped authorship and a fired-rule-id snapshot for join-free rule-quality evaluation. Per-lane write matrix re-gated in-function with own-tenant binding (a GA writes only the platform-internal `globaladmin` lane cross-tenant; tenant callers never see that lane). Surfaces: portal `SessionAnnotationsCard` (session detail, after Analysis), MCP `list_session_annotations` (ga) + `annotate_session` (strictGa, always the GA lane) + `annotations` key in `get_session_summary`. Lifecycle: per-session cascade steps 17–19, offboarding wipe, critical-backup member.
## 2026-08-11
* **Update**: `rules/gather-rule-guardrails.md` — hard-block mirroring closed across all pre-flight layers: `rules/guardrails.json` now also mirrors `blockedCommandPatterns` + `maxCommandLength` and carries `C:\Windows\System32\config` in `blockedFilePrefixes`; portal (`guardValidation.ts`) and MCP (`rule-validation.ts`) apply them in agent order (before the unrestricted-mode early-return), so a command the agent hard-blocks no longer shows "Allowed" in unrestricted tenants. `GuardrailsParityTests` pins the JSON mirror to the C# constants. MCP `validate_rule` additionally validates `json`/`xml` targets with file semantics (the schema's `collectorType` enum was missing both) and rejects `activePhases`+`activeFromPhase` set together (backend 400 parity). Author stamping extended to PUT-upserts: tenant and global gather-rule updates stamp the caller's JWT display name before the service call, so an upsert of a non-existing ruleId can no longer store a payload-supplied author (true updates still preserve the original author).
## 2026-08-10
* **Update**: `rules/gather-rule-guardrails.md` — WMI matcher generalized from literal prefix matching to shape parsing: `SELECT <* | property-list> FROM <class>` is allowed when the class is derived from the existing `SELECT * FROM <class>` entries (a projection is a strict subset; motivated by `SELECT BatteryStatus FROM Win32_Battery` + `on_change` battery polling that `SELECT *` defeats via fluctuating charge %). Same algorithm mirrored agent/portal/MCP; guardrails.json unchanged. Portal now also gates enabling on the client-side validation (invalid-target custom rules create disabled, cannot be toggled on, force-disable on invalid edit, "Blocked on devices" badge) — UX only, the agent guard stays the boundary. Gather-rule Author is now stamped from the creator's JWT (display name → UPN) at create and immutable on update.
+8 -3
View File
@@ -7,12 +7,12 @@ description: >-
guard rather than trusting rule.Target.
resource: src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/Gather
tags: [gather-rules, security, guardrails, agent]
timestamp: 2026-08-10
timestamp: 2026-08-11
---
# Concept
A gather rule is authored by a **tenant administrator** through `POST /api/rules/gather` and executed by the agent as **SYSTEM** on an enrolling device. The backend does not validate `collectorType`, `target`, or `parameters` at all — it only enforces scope/emit-mode fields, a 1 MB body cap, and that the rule lands in the caller's own tenant partition. Everything that decides *what a rule may reach* is therefore enforced **on the agent**, in `GatherRuleGuards`.
A gather rule is authored by a **tenant administrator** through `POST /api/rules/gather` and executed by the agent as **SYSTEM** on an enrolling device. The backend does not validate `collectorType`, `target`, or `parameters` at all — it only enforces scope/emit-mode fields, a 1 MB body cap, that the rule lands in the caller's own tenant partition, and that `Author` is stamped from the caller's JWT (create and PUT-upsert; true updates preserve the original author). Everything that decides *what a rule may reach* is therefore enforced **on the agent**, in `GatherRuleGuards`.
This is the security boundary. The portal's `ValidationIndicator` is a convenience — the API stays reachable directly and the backend stays validation-free — but since 2026-08 the portal additionally **gates enabling** on the same client-side validation: a custom rule whose target fails validation cannot be toggled on, is created `enabled=false`, and an edit that makes the target invalid force-disables the rule (`targetBlocked` in `app/gather-rules/types.ts`). An enabled rule with an invalid target shows a "Blocked on devices" badge. All of this is UX, not security — the agent guard remains the enforcement point.
@@ -44,7 +44,12 @@ but the hard blocks must hold regardless of configuration:
PowerShell channels (script-block logging routinely contains secrets in clear text).
`rules/guardrails.json` mirrors the blocked lists (`blockedFilePrefixes`,
`blockedEventLogChannels`) **for display only**. Enforcement is the C# constant.
`blockedEventLogChannels`, `blockedCommandPatterns`, `maxCommandLength`) **for display
and pre-flight only**. Enforcement is the C# constant; `GuardrailsParityTests` (agent
test project) pins the JSON mirror to the constants so neither side can drift. The
portal (`utils/guardValidation.ts`) and the MCP validator (`rule-validation.ts`) apply
the mirrored hard blocks in the same order as the agent — before the unrestricted-mode
early-return — so a rule the agent would refuse is never shown as "Allowed".
# Examples
+23 -2
View File
@@ -250,6 +250,27 @@
"C:\\Install\\Log"
],
"blockedFilePrefixes": [
"C:\\Users"
]
"C:\\Users",
"C:\\Windows\\System32\\config"
],
"blockedCommandPatterns": [
"Invoke-WebRequest",
"Invoke-RestMethod",
"Start-BitsTransfer",
"wget",
"curl",
"certutil -urlcache",
"New-LocalUser",
"Add-LocalGroupMember",
"net user",
"net localgroup",
"bcdedit",
"bcdboot",
"schtasks /create",
"Register-ScheduledTask",
"Remove-Item -Recurse",
"Format-Volume",
"Set-ExecutionPolicy"
],
"maxCommandLength": 2000
}
+1 -1
View File
@@ -67,7 +67,7 @@
"collectorType": {
"type": "string",
"description": "Type of data collection.",
"enum": ["registry", "eventlog", "wmi", "file", "command_allowlisted", "logparser"]
"enum": ["registry", "eventlog", "wmi", "file", "json", "xml", "command_allowlisted", "logparser"]
},
"target": {
"type": "string",
+6
View File
@@ -154,6 +154,12 @@ ${flatArray(flatEventLogChannels)}
export const BLOCKED_EVENT_LOG_CHANNELS: readonly string[] = [
${flatArray(guardrails.blockedEventLogChannels)}
];
export const BLOCKED_COMMAND_PATTERNS: readonly string[] = [
${flatArray(guardrails.blockedCommandPatterns)}
];
export const MAX_COMMAND_LENGTH = ${guardrails.maxCommandLength};
`;
fs.writeFileSync(guardrailsOutput, ts, 'utf8');
@@ -107,6 +107,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Ime
var ok = CmTraceLogParser.TryParseLine(line, out var entry);
Assert.True(ok);
Assert.NotNull(entry);
Assert.Equal(
new DateTime(2026, 7, 29, 11, 46, 19, DateTimeKind.Utc).AddTicks(226610).AddMinutes(expectedOffsetMinutes),
entry.Timestamp);
@@ -0,0 +1,69 @@
using System.IO;
using System.Linq;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather;
using Newtonsoft.Json.Linq;
using Xunit;
namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Telemetry.Gather
{
/// <summary>
/// Pins the display mirror in rules/guardrails.json (embedded into this assembly)
/// to the agent's hard-block CONSTANTS in <see cref="GatherRuleGuards"/>.
/// Enforcement is code-only by design (a JSON parse error must never lift a hard
/// block); the JSON mirror exists so the portal and the MCP validator can
/// pre-flight the same blocks. This test keeps the two from drifting: an addition
/// on either side fails here until the other side follows.
/// </summary>
public sealed class GuardrailsParityTests
{
private static JObject LoadEmbeddedGuardrails()
{
var assembly = typeof(GatherRuleGuards).Assembly;
using var stream = assembly.GetManifestResourceStream(
"AutopilotMonitor.Agent.V2.Core.Resources.guardrails.json");
Assert.NotNull(stream);
using var reader = new StreamReader(stream!);
return JObject.Parse(reader.ReadToEnd());
}
[Fact]
public void Blocked_file_prefixes_match_code_constants()
{
var json = LoadEmbeddedGuardrails()["blockedFilePrefixes"]!.ToObject<string[]>()!;
var code = new[] { GatherRuleGuards.BlockedUsersPrefix }
.Concat(GatherRuleGuards.AdditionalHardBlockedPathPrefixes);
Assert.Equal(
code.Select(p => p.ToLowerInvariant()).OrderBy(p => p),
json.Select(p => Path.GetFullPath(p).ToLowerInvariant()).OrderBy(p => p));
}
[Fact]
public void Blocked_command_patterns_match_code_constants()
{
var json = LoadEmbeddedGuardrails()["blockedCommandPatterns"]!.ToObject<string[]>()!;
Assert.Equal(
GatherRuleGuards.HardBlockedCommandPatterns.OrderBy(p => p),
json.OrderBy(p => p));
}
[Fact]
public void Max_command_length_matches_code_constant()
{
Assert.Equal(
GatherRuleGuards.MaxCommandLength,
LoadEmbeddedGuardrails()["maxCommandLength"]!.Value<int>());
}
[Fact]
public void Blocked_event_log_channels_match_code_constants()
{
var json = LoadEmbeddedGuardrails()["blockedEventLogChannels"]!.ToObject<string[]>()!;
Assert.Equal(
GatherRuleGuards.HardBlockedEventLogChannels.OrderBy(c => c),
json.OrderBy(c => c));
}
}
}
@@ -0,0 +1,124 @@
using System;
using System.IO;
using System.Threading.Tasks;
using AutopilotMonitor.Agent.V2.Core.Logging;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime;
using AutopilotMonitor.Agent.V2.Core.Tests.Harness;
using AutopilotMonitor.Agent.V2.Runtime;
using Xunit;
namespace AutopilotMonitor.Agent.V2.Core.Tests.Program
{
/// <summary>
/// Contract tests for <see cref="AgentBootstrap.RetrySelfUpdateAfterEnrollment"/> —
/// the post-cert one-shot self-update retry (field case sits-d.cloud, 2026-08-09):
/// guard on <see cref="SelfUpdater.LastVersionCheckSucceeded"/>, the relaxed
/// timeouts/trigger it passes to the updater, and the swallow-everything exception
/// contract (bootstrap must continue on any updater failure). Uses the
/// <see cref="AgentBootstrap.UpdateInvokerOverride"/> seam — no network, no process
/// side effects.
/// <para>
/// Static-hook state: each test saves/restores both statics in ctor/Dispose so
/// parallel-running test classes never observe a leaked value (idiom of
/// SelfUpdaterGracefulShutdownTests).
/// </para>
/// </summary>
public sealed class AgentBootstrapSelfUpdateRetryTests : IDisposable
{
private readonly AgentBootstrap.SelfUpdateInvoker _savedInvoker;
private readonly bool _savedVersionCheckFlag;
public AgentBootstrapSelfUpdateRetryTests()
{
_savedInvoker = AgentBootstrap.UpdateInvokerOverride;
_savedVersionCheckFlag = SelfUpdater.LastVersionCheckSucceeded;
AgentBootstrap.UpdateInvokerOverride = null;
SelfUpdater.LastVersionCheckSucceeded = false;
}
public void Dispose()
{
AgentBootstrap.UpdateInvokerOverride = _savedInvoker;
SelfUpdater.LastVersionCheckSucceeded = _savedVersionCheckFlag;
}
private static AgentLogger NewLogger(string path)
=> new AgentLogger(Path.Combine(path, "logs"), AgentLogLevel.Info);
[Fact]
public void Skips_retry_when_startup_version_check_already_succeeded()
{
using var tmp = new TempDirectory();
var invoked = false;
SelfUpdater.LastVersionCheckSucceeded = true;
AgentBootstrap.UpdateInvokerOverride = (v, dir, console, reason, dl, downgrade, vc) =>
{
invoked = true;
return Task.CompletedTask;
};
AgentBootstrap.RetrySelfUpdateAfterEnrollment(NewLogger(tmp.Path), consoleMode: false);
Assert.False(invoked);
}
[Fact]
public void Retries_with_await_enrollment_trigger_and_relaxed_timeouts()
{
using var tmp = new TempDirectory();
string? capturedVersion = null, capturedDir = null, capturedReason = null;
bool? capturedConsole = null, capturedDowngrade = null;
int capturedDownloadMs = 0, capturedVersionCheckMs = 0;
AgentBootstrap.UpdateInvokerOverride = (v, dir, console, reason, dl, downgrade, vc) =>
{
capturedVersion = v;
capturedDir = dir;
capturedConsole = console;
capturedReason = reason;
capturedDownloadMs = dl;
capturedDowngrade = downgrade;
capturedVersionCheckMs = vc;
return Task.CompletedTask;
};
AgentBootstrap.RetrySelfUpdateAfterEnrollment(NewLogger(tmp.Path), consoleMode: true);
Assert.Equal("await_enrollment", capturedReason);
// Relaxed vs. the boot-speed-tuned startup check (10s/2.5s) — enrollment is
// already past OOBE's critical path here.
Assert.Equal(60000, capturedDownloadMs);
Assert.Equal(10000, capturedVersionCheckMs);
// No cached remote config on a fresh --await-enrollment install → no downgrade override.
Assert.False(capturedDowngrade);
Assert.True(capturedConsole);
Assert.False(string.IsNullOrEmpty(capturedVersion));
Assert.False(string.IsNullOrEmpty(capturedDir));
}
[Fact]
public void Synchronous_invoker_exception_is_swallowed()
{
using var tmp = new TempDirectory();
AgentBootstrap.UpdateInvokerOverride =
(v, dir, console, reason, dl, downgrade, vc) => throw new InvalidOperationException("boom");
var ex = Record.Exception(
() => AgentBootstrap.RetrySelfUpdateAfterEnrollment(NewLogger(tmp.Path), consoleMode: false));
Assert.Null(ex);
}
[Fact]
public void Faulted_update_task_is_swallowed()
{
using var tmp = new TempDirectory();
AgentBootstrap.UpdateInvokerOverride = (v, dir, console, reason, dl, downgrade, vc) =>
Task.FromException(new IOException("download interrupted"));
var ex = Record.Exception(
() => AgentBootstrap.RetrySelfUpdateAfterEnrollment(NewLogger(tmp.Path), consoleMode: false));
Assert.Null(ex);
}
}
}
@@ -16,7 +16,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Program
/// </summary>
public sealed class TenantIdWaitResolutionTests
{
private static AgentConfiguration Build(BootstrapConfigFile bootstrapConfig, params string[] args) =>
private static AgentConfiguration Build(BootstrapConfigFile? bootstrapConfig, params string[] args) =>
AutopilotMonitor.Agent.V2.Program.BuildAgentConfiguration(
args: args,
tenantId: "tenant-t",
@@ -1,5 +1,6 @@
using System.Collections.Generic;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.DeviceInfo;
using AutopilotMonitor.Shared.Models;
using Xunit;
namespace AutopilotMonitor.Agent.V2.Core.Tests.Telemetry.DeviceInfo;
@@ -109,4 +110,28 @@ public class DeviceInfoCollectorAutopilotProfileMissingTests
Assert.False(DeviceInfoCollector.TryExtractZeroTouchTenantDomain(null, out var domain));
Assert.Null(domain);
}
[Fact]
public void ResolveAutopilotProfileMissingPresentation_PhysicalDevice_WarnsAboutAssignment()
{
var (message, severity) = DeviceInfoCollector.ResolveAutopilotProfileMissingPresentation(isCloudPc: false);
Assert.Equal(EventSeverity.Warning, severity);
Assert.Equal(
"No Autopilot profile was available during OOBE (ProfileAvailable=0) — most likely no deployment profile was assigned to the device, or the assignment had not finished propagating when OOBE ran. OOBE proceeded as a standard Entra ID join instead of an Autopilot deployment.",
message);
}
[Fact]
public void ResolveAutopilotProfileMissingPresentation_CloudPc_InformationalAndExpected()
{
// On a Windows 365 Cloud PC a missing Autopilot profile is the normal state —
// the event must not read as an alarm there.
var (message, severity) = DeviceInfoCollector.ResolveAutopilotProfileMissingPresentation(isCloudPc: true);
Assert.Equal(EventSeverity.Info, severity);
Assert.Contains("Windows 365 Cloud PC", message);
Assert.Contains("expected", message);
Assert.DoesNotContain("no deployment profile was assigned", message);
}
}
@@ -98,7 +98,12 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime
/// connectivity — the bootstrap retries the check exactly when this is still false.
/// </para>
/// </summary>
public static bool LastVersionCheckSucceeded { get; private set; }
/// <remarks>
/// Setter is internal solely as a test seam (InternalsVisibleTo): the bootstrap-retry
/// tests arm/reset the guard without running the real network check. Production code
/// writes it only from <see cref="CheckAndApplyUpdateAsync"/>.
/// </remarks>
public static bool LastVersionCheckSucceeded { get; internal set; }
/// <summary>
/// Optional: graceful-shutdown request hook, set by the runtime host once its
@@ -305,10 +305,16 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.DeviceInfo
// (see docs/agent/autopilot-ztd-diagnostics.md for the evidence model).
if (IsAutopilotProfileMissing(data))
{
// On Windows 365 Cloud PCs a missing profile is the expected state — Cloud PCs
// are provisioned by the Windows 365 service, not by Autopilot — so the event
// is demoted to Info there instead of raising a misleading warning.
var isCloudPc = CloudPcDetector.DetectIsCloudPc();
var missingData = new Dictionary<string, object>
{
{ "profileAvailable", "0" },
{ "likelyCauses", "profile_not_assigned,profile_assignment_not_propagated" }
{ "likelyCauses", "profile_not_assigned,profile_assignment_not_propagated" },
{ "isCloudPc", isCloudPc }
};
if (TryExtractZeroTouchTenantDomain(data, out var ztdTenantDomain))
{
@@ -347,12 +353,17 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.DeviceInfo
missingData["ztdEndpointLatencyMs"] = probe.LatencyMs;
missingData["ztdEndpointDetail"] = probe.Detail;
var presentation = ResolveAutopilotProfileMissingPresentation(isCloudPc);
EmitDeviceInfoEvent(Constants.EventTypes.AutopilotProfileMissing,
"No Autopilot profile was available during OOBE (ProfileAvailable=0) — most likely no deployment profile was assigned to the device, or the assignment had not finished propagating when OOBE ran. OOBE proceeded as a standard Entra ID join instead of an Autopilot deployment.",
presentation.Message,
missingData,
EventSeverity.Warning);
presentation.Severity);
_logger.Warning($"EnrollmentTracker: no Autopilot profile available during OOBE (ProfileAvailable=0) — ztdVerdict={missingData["ztdVerdict"]}, endpointReachable={probe.Reachable}");
var logLine = $"EnrollmentTracker: no Autopilot profile available during OOBE (ProfileAvailable=0) — isCloudPc={isCloudPc}, ztdVerdict={missingData["ztdVerdict"]}, endpointReachable={probe.Reachable}";
if (isCloudPc)
_logger.Info(logLine);
else
_logger.Warning(logLine);
}
// Emit dedicated enrollment_type_detected event for easy filtering. Routed through
@@ -396,6 +407,26 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.DeviceInfo
&& profileAvailable?.ToString() == "0";
}
/// <summary>
/// Message and severity for the autopilot_profile_missing event. On Windows 365
/// Cloud PCs the missing profile is expected (Cloud PCs are provisioned by the
/// Windows 365 service, not by Autopilot), so the event is informational there;
/// on physical devices it stays a warning about a missing profile ASSIGNMENT.
/// </summary>
internal static (string Message, EventSeverity Severity) ResolveAutopilotProfileMissingPresentation(bool isCloudPc)
{
if (isCloudPc)
{
return (
"No Autopilot profile was available during OOBE (ProfileAvailable=0). This device is a Windows 365 Cloud PC — Cloud PCs are provisioned by the Windows 365 service and do not use Autopilot deployment profiles, so this is expected. OOBE ran as a standard Entra ID join, which is the normal path for Cloud PCs.",
EventSeverity.Info);
}
return (
"No Autopilot profile was available during OOBE (ProfileAvailable=0) — most likely no deployment profile was assigned to the device, or the assignment had not finished propagating when OOBE ran. OOBE proceeded as a standard Entra ID join instead of an Autopilot deployment.",
EventSeverity.Warning);
}
/// <summary>
/// Extracts ZeroTouchConfig.CloudAssignedTenantDomain from the cached profile data.
/// The value lives in the CloudAssignedAadServerData JSON — either as a top-level
@@ -54,9 +54,11 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather
@"^SELECT\s+(?:\*|[A-Za-z_][A-Za-z0-9_]*(?:\s*,\s*[A-Za-z_][A-Za-z0-9_]*)*)\s+FROM\s+([A-Za-z_][A-Za-z0-9_]*)(?=\s|$)",
RegexOptions.IgnoreCase | RegexOptions.Compiled);
// Hard blocks: never allowed, even in unrestricted mode
private static readonly string BlockedUsersPrefix = Path.GetFullPath(@"C:\Users");
private static readonly string[] AdditionalHardBlockedPathPrefixes = new[]
// Hard blocks: never allowed, even in unrestricted mode. Internal (not private)
// so GuardrailsParityTests can pin the guardrails.json display mirror to these
// constants — enforcement stays code-only by design.
internal static readonly string BlockedUsersPrefix = Path.GetFullPath(@"C:\Users");
internal static readonly string[] AdditionalHardBlockedPathPrefixes = new[]
{
Path.GetFullPath(@"C:\Windows\System32\config"), // SAM, SECURITY, SYSTEM hives
};
@@ -69,14 +71,14 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather
};
// Hard limit: maximum command length (even in unrestricted mode)
private const int MaxCommandLength = 2000;
internal const int MaxCommandLength = 2000;
// Event log channels that are never readable, even in unrestricted mode.
// Security carries the audit trail of user behaviour; the PowerShell channels
// carry script-block logging, which routinely contains secrets in clear text.
// Mirrored in rules/guardrails.json ("blockedEventLogChannels") for the portal
// to display — enforcement lives here so a parse error cannot lift it.
private static readonly string[] HardBlockedEventLogChannels = new[]
internal static readonly string[] HardBlockedEventLogChannels = new[]
{
"Security",
"Microsoft-Windows-PowerShell",
@@ -86,7 +88,9 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather
// Hard-blocked command patterns: never allowed, even in unrestricted mode.
// These prevent privilege escalation, persistence, and data exfiltration.
private static readonly string[] HardBlockedCommandPatterns = new[]
// Mirrored in rules/guardrails.json ("blockedCommandPatterns") for portal/MCP
// pre-flight display — enforcement lives here so a parse error cannot lift it.
internal static readonly string[] HardBlockedCommandPatterns = new[]
{
// Download / exfiltration
"Invoke-WebRequest", "Invoke-RestMethod", "Start-BitsTransfer",
@@ -1,6 +1,7 @@
using System;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using AutopilotMonitor.Agent.V2.Core.Configuration;
using AutopilotMonitor.Agent.V2.Core.Logging;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime;
@@ -155,6 +156,28 @@ namespace AutopilotMonitor.Agent.V2.Runtime
cleanupServiceFactory: cleanupServiceFactory);
}
/// <summary>
/// Signature of the self-update invocation performed by
/// <see cref="RetrySelfUpdateAfterEnrollment"/> — mirrors the
/// <see cref="SelfUpdater.CheckAndApplyUpdateAsync"/> parameters this path uses.
/// </summary>
internal delegate Task SelfUpdateInvoker(
string currentVersion,
string agentDir,
bool consoleMode,
string triggerReason,
int downloadTimeoutMs,
bool allowDowngrade,
int versionCheckTimeoutMs);
/// <summary>
/// Test seam replacing the real <see cref="SelfUpdater.CheckAndApplyUpdateAsync"/> call
/// in <see cref="RetrySelfUpdateAfterEnrollment"/> (same static-hook idiom as
/// <see cref="SelfUpdater.RequestGracefulShutdown"/>). Only the in-repo test project
/// (InternalsVisibleTo) can set it; production code never assigns this.
/// </summary>
internal static SelfUpdateInvoker UpdateInvokerOverride;
/// <summary>
/// One-shot self-update retry after the MDM certificate appeared. The startup check in
/// <c>Main</c> runs pre-enrollment, where an --await-enrollment install typically has no
@@ -166,7 +189,7 @@ namespace AutopilotMonitor.Agent.V2.Runtime
/// On update success the process restarts and this method never returns; the
/// await-enrollment config was already removed, so the new binary proceeds normally.
/// </summary>
private static void RetrySelfUpdateAfterEnrollment(AgentLogger logger, bool consoleMode)
internal static void RetrySelfUpdateAfterEnrollment(AgentLogger logger, bool consoleMode)
{
if (SelfUpdater.LastVersionCheckSucceeded)
return; // Startup check reached the manifest — its verdict stands.
@@ -176,16 +199,29 @@ namespace AutopilotMonitor.Agent.V2.Runtime
logger.Info("Await-enrollment: startup self-update check had failed (no network in early OOBE) — retrying now that enrollment proves connectivity.");
var agentDir = System.IO.Path.GetDirectoryName(
System.Reflection.Assembly.GetExecutingAssembly().Location) ?? string.Empty;
SelfUpdater.CheckAndApplyUpdateAsync(
currentVersion: Program.GetAgentVersion(),
agentDir: agentDir,
consoleMode: consoleMode,
triggerReason: "await_enrollment",
downloadTimeoutMsOverride: 60000,
SelfUpdateInvoker invoke = UpdateInvokerOverride;
if (invoke == null)
{
invoke = (version, dir, console, reason, downloadTimeoutMs, allowDowngrade, versionCheckTimeoutMs) =>
SelfUpdater.CheckAndApplyUpdateAsync(
currentVersion: version,
agentDir: dir,
consoleMode: console,
triggerReason: reason,
downloadTimeoutMsOverride: downloadTimeoutMs,
allowDowngrade: allowDowngrade,
versionCheckTimeoutMsOverride: versionCheckTimeoutMs);
}
invoke(
Program.GetAgentVersion(),
agentDir,
consoleMode,
"await_enrollment",
60000,
// Fresh --await-enrollment installs have no cached remote config yet, so
// there is no admin downgrade override to honour on this path.
allowDowngrade: false,
versionCheckTimeoutMsOverride: 10000).GetAwaiter().GetResult();
false,
10000).GetAwaiter().GetResult();
}
catch (Exception ex)
{
@@ -8,6 +8,11 @@
</PropertyGroup>
<ItemGroup>
<!-- Same compile context as the product project: with the shared framework referenced,
NuGet prunes framework-provided transitive packages (e.g. the vulnerable
DataProtection/Cryptography.Xml 10.0.0 pulled in via Microsoft.Identity.Web),
which would otherwise surface NU1903/NU1904 advisories here. -->
<FrameworkReference Include="Microsoft.AspNetCore.App" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.11.1" />
<PackageReference Include="Moq" Version="4.20.72" />
<PackageReference Include="xunit" Version="2.9.2" />
@@ -47,7 +47,7 @@ public class CriticalTableBackupServiceTests
Assert.Equal(BackupOutcome.Success, result.Outcome);
Assert.True(store.ManifestWritten, "manifest must be written LAST");
Assert.Equal(17, result.Manifest.Tables.Count); // critical-tables catalog count (incl. DelegatedAdmins + ConfigurationBackups)
Assert.Equal(18, result.Manifest.Tables.Count); // critical-tables catalog count (incl. DelegatedAdmins + ConfigurationBackups + SessionAnnotations)
Assert.All(result.Manifest.Tables, t =>
Assert.True(t.Status == TableBackupStatus.Ok || t.Status == TableBackupStatus.Empty));
}
@@ -75,9 +75,9 @@ public class DeletionManifestBuilderTests
Assert.Equal(2, c["classifierVerdictsByIdLevel"]);
Assert.Equal(11, c["signalsByKind"]);
// 16 cascade tables (incl. SessionTimeBreakdowns, F1 PR2) + tombstone = 17 steps;
// no inventory steps when no contributions row.
Assert.Equal(17, manifest.Steps.Count);
// 19 cascade tables (incl. SessionTimeBreakdowns F1 PR2 + 3 SessionAnnotations lanes)
// + tombstone = 20 steps; no inventory steps when no contributions row.
Assert.Equal(20, manifest.Steps.Count);
Assert.Equal(DeletionStepClass.Final, manifest.Steps.Last().Class);
}
@@ -104,7 +104,8 @@ public class DeletionManifestBuilderTests
.WithDeadEndsByReason(1, propValue: "deadend_marker")
.WithClassifierVerdictsByIdLevel(1, propValue: "classifier_marker")
.WithSignalsByKind(1, propValue: "signalkind_marker")
.WithSessionTimeBreakdown(propValue: "breakdown_marker"));
.WithSessionTimeBreakdown(propValue: "breakdown_marker")
.WithSessionAnnotations(propValue: "annotation_marker"));
var manifest = await NewBuilder(reader).BuildAsync(
TenantId, SessionId, "admin_delete",
@@ -147,8 +148,9 @@ public class DeletionManifestBuilderTests
new DeletionRetentionContext { TenantRetentionDays = 90 });
var nonTombstoneSteps = manifest.Steps.Where(s => s.Class != DeletionStepClass.Final).ToList();
// Without a contributions row, neither inventory step is emitted → exactly 16 cascade-table steps.
Assert.Equal(16, nonTombstoneSteps.Count);
// Without a contributions row, neither inventory step is emitted → exactly 19 cascade-table steps
// (16 + the 3 SessionAnnotations lane steps).
Assert.Equal(19, nonTombstoneSteps.Count);
foreach (var step in nonTombstoneSteps)
{
Assert.Equal(0, step.RowCount);
@@ -177,8 +179,8 @@ public class DeletionManifestBuilderTests
new DeletionActor { Type = "admin", Actor = "alice@example.com" },
new DeletionRetentionContext { TenantRetentionDays = 90 });
// Expect: 16 cascade + 2 inventory + 1 tombstone = 19 steps.
Assert.Equal(19, manifest.Steps.Count);
// Expect: 19 cascade + 2 inventory + 1 tombstone = 22 steps.
Assert.Equal(22, manifest.Steps.Count);
var aggregate = manifest.Steps.Single(s => s.Class == DeletionStepClass.Aggregate);
Assert.Equal(DeletionStepNames.SoftwareInventoryDecrement, aggregate.Step);
@@ -229,7 +231,7 @@ public class DeletionManifestBuilderTests
[Fact]
public async Task Build_omits_inventory_steps_when_no_side_row()
{
// The default reader returns no inventory row → 15 cascade + 1 tombstone, no inventory steps.
// The default reader returns no inventory row → 19 cascade + 1 tombstone, no inventory steps.
var reader = NewReader(seed => seed.WithSessionsRow().WithSessionsIndexRow());
var manifest = await NewBuilder(reader).BuildAsync(
@@ -644,6 +646,7 @@ public class DeletionManifestBuilderTests
private TableEntity? _eventSessionIndex;
private TableEntity? _sessionTimeBreakdown;
private TableEntity? _sessionInventoryContributions;
private readonly Dictionary<string, TableEntity> _sessionAnnotations = new(StringComparer.Ordinal);
private string _sessionsIndexRowKey = "INDEX_" + SessionId;
public ReaderSeed WithSessionsRow(string? diagnosticsBlobName = null)
@@ -774,6 +777,23 @@ public class DeletionManifestBuilderTests
public ReaderSeed WithClassifierVerdictsByIdLevel(int n, string propValue = "cls") => SeedDiscriminatorPkProp(Constants.TableNames.ClassifierVerdictsByIdLevel, n, propValue, "EspApps_High");
public ReaderSeed WithSignalsByKind(int n, string propValue = "sk") => SeedDiscriminatorPkProp(Constants.TableNames.SignalsByKind, n, propValue, "EspPhaseChanged");
public ReaderSeed WithSessionAnnotations(string propValue = "anno")
{
// One row per lane — the builder emits one exact-RK step per lane.
foreach (var lane in AutopilotMonitor.Shared.Models.AnnotationLanes.All)
{
var e = new TableEntity(TenantId, $"{SessionId}_{lane}")
{
["Marker"] = propValue,
["SessionId"] = SessionId,
["Lane"] = lane,
};
e.ETag = new ETag("0xANNO" + lane);
_sessionAnnotations[lane] = e;
}
return this;
}
public ReaderSeed WithSessionInventoryContributions(TableEntity row)
{
_sessionInventoryContributions = row;
@@ -813,6 +833,11 @@ public class DeletionManifestBuilderTests
.ReturnsAsync(_sessionTimeBreakdown);
reader.Setup(r => r.GetEntityOrNullAsync(Constants.TableNames.SessionInventoryContributions, TenantId, SessionId, It.IsAny<CancellationToken>()))
.ReturnsAsync(_sessionInventoryContributions);
foreach (var (lane, row) in _sessionAnnotations)
{
reader.Setup(r => r.GetEntityOrNullAsync(Constants.TableNames.SessionAnnotations, TenantId, $"{SessionId}_{lane}", It.IsAny<CancellationToken>()))
.ReturnsAsync(row);
}
// QueryAsync: per-table seeded list, otherwise empty.
reader.Setup(r => r.QueryAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
@@ -0,0 +1,185 @@
using System.Net;
using System.Security.Claims;
using System.Text;
using AutopilotMonitor.Functions.Functions.Rules;
using AutopilotMonitor.Functions.Services;
using AutopilotMonitor.Shared.DataAccess;
using AutopilotMonitor.Shared.Models;
using Azure.Core.Serialization;
using Microsoft.Azure.Functions.Worker;
using Microsoft.Azure.Functions.Worker.Http;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
using Moq;
using Newtonsoft.Json;
namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// Endpoint-level tests proving <see cref="GatherRulesFunction"/> stamps the rule
/// Author from the caller's JWT and never from the request body — through the real
/// request pipeline (body deserialization → ValidateScopeAndEmitMode → stamp →
/// service). Complements <see cref="GatherRuleAuthorStampingTests"/> (claim
/// precedence only) and GatherRuleUpdatePartialMergeTests (service-level
/// preservation): those pin the pieces, this pins the wiring.
/// Unlike most function tests in this project, it fakes HttpRequestData — the
/// stamp lives IN the endpoint method, so no smaller seam can prove it.
/// </summary>
public class GatherRulesFunctionAuthorEndpointTests
{
private const string TenantId = "a1b2c3d4-e5f6-7890-abcd-ef1234567890";
private const string RuleId = "GATHER-CUSTOM-777";
// ── request/response fakes ──────────────────────────────────────────────
private sealed class FakeHttpResponseData : HttpResponseData
{
public FakeHttpResponseData(FunctionContext context) : base(context) { }
public override HttpStatusCode StatusCode { get; set; } = HttpStatusCode.OK;
public override HttpHeadersCollection Headers { get; set; } = new();
public override Stream Body { get; set; } = new MemoryStream();
public override HttpCookies Cookies { get; } = new Mock<HttpCookies>().Object;
}
private static FunctionContext BuildContext(ClaimsPrincipal? principal)
{
// WriteAsJsonAsync resolves the serializer from InstanceServices.
var services = new ServiceCollection();
services.AddOptions();
services.Configure<WorkerOptions>(o => o.Serializer = new JsonObjectSerializer());
var provider = services.BuildServiceProvider();
var items = new Dictionary<object, object>();
if (principal != null)
{
items["ClaimsPrincipal"] = principal;
}
var context = new Mock<FunctionContext>();
context.SetupGet(c => c.Items).Returns(items);
context.SetupGet(c => c.InstanceServices).Returns(provider);
return context.Object;
}
private static HttpRequestData BuildRequest(ClaimsPrincipal? principal, object body)
{
var context = BuildContext(principal);
var req = new Mock<HttpRequestData>(context);
req.SetupGet(r => r.Headers).Returns(new HttpHeadersCollection());
req.SetupGet(r => r.Body).Returns(
new MemoryStream(Encoding.UTF8.GetBytes(JsonConvert.SerializeObject(body))));
req.Setup(r => r.CreateResponse()).Returns(() => new FakeHttpResponseData(context));
return req.Object;
}
private static ClaimsPrincipal Principal(params (string Type, string Value)[] claims)
=> new(new ClaimsIdentity(claims.Select(c => new Claim(c.Type, c.Value)), "TestAuth"));
private static ClaimsPrincipal AlicePrincipal()
=> Principal(("tid", TenantId), ("name", "Alice Admin"), ("upn", "alice@contoso.com"));
// ── function under test with a captured repository ─────────────────────
private static (GatherRulesFunction function, List<GatherRule> stored) BuildFunction(
GatherRule? existingTenantRule = null)
{
var repo = new Mock<IRuleRepository>(MockBehavior.Loose);
var stored = new List<GatherRule>();
repo.Setup(r => r.GatherRuleExistsAsync(It.IsAny<string>(), It.IsAny<string>())).ReturnsAsync(false);
repo.Setup(r => r.GetGatherRulesAsync("global")).ReturnsAsync(new List<GatherRule>());
repo.Setup(r => r.GetGatherRulesAsync(TenantId)).ReturnsAsync(
existingTenantRule == null ? new List<GatherRule>() : new List<GatherRule> { existingTenantRule });
repo.Setup(r => r.StoreGatherRuleAsync(It.IsAny<GatherRule>(), TenantId))
.Callback<GatherRule, string>((rule, _) => stored.Add(rule))
.ReturnsAsync(true);
var service = new GatherRuleService(repo.Object, NullLogger<GatherRuleService>.Instance);
return (new GatherRulesFunction(NullLogger<GatherRulesFunction>.Instance, service), stored);
}
private static GatherRule SpoofedPayload() => new()
{
RuleId = RuleId,
Title = "Collect BIOS Config",
CollectorType = "registry",
Target = "HKLM\\SOFTWARE\\RealmJoin\\Custom\\BIOS",
Trigger = "startup",
OutputEventType = "gather_bios_config",
Enabled = true,
Author = "Spoofed Author",
};
// ── CreateRule ──────────────────────────────────────────────────────────
[Fact]
public async Task CreateRule_stamps_author_from_jwt_not_from_body()
{
var (function, stored) = BuildFunction();
var req = BuildRequest(AlicePrincipal(), SpoofedPayload());
var response = await function.CreateRule(req);
Assert.Equal(HttpStatusCode.Created, response.StatusCode);
var written = Assert.Single(stored);
Assert.Equal("Alice Admin", written.Author);
}
[Fact]
public async Task CreateRule_falls_back_to_product_name_without_identifying_claims()
{
// App-only-shaped token: authenticated with a tenant but no user display claims.
var (function, stored) = BuildFunction();
var req = BuildRequest(Principal(("tid", TenantId)), SpoofedPayload());
var response = await function.CreateRule(req);
Assert.Equal(HttpStatusCode.Created, response.StatusCode);
var written = Assert.Single(stored);
Assert.Equal("Autopilot Monitor", written.Author);
}
// ── UpdateRule ──────────────────────────────────────────────────────────
[Fact]
public async Task UpdateRule_upsert_of_unknown_rule_stamps_author_from_jwt()
{
// Full-payload PUT for a ruleId with no existing tenant row upserts a new row —
// without the endpoint stamp the payload's author would be stored verbatim.
var (function, stored) = BuildFunction(existingTenantRule: null);
var req = BuildRequest(AlicePrincipal(), SpoofedPayload());
var response = await function.UpdateRule(req, RuleId);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var written = Assert.Single(stored);
Assert.Equal("Alice Admin", written.Author);
}
[Fact]
public async Task UpdateRule_true_update_keeps_original_author_over_editor_and_body()
{
var existing = new GatherRule
{
RuleId = RuleId,
Title = "Collect BIOS Config",
CollectorType = "registry",
Target = "HKLM\\SOFTWARE\\RealmJoin\\Custom\\BIOS",
Trigger = "startup",
OutputEventType = "gather_bios_config",
IsBuiltIn = false,
IsCommunity = false,
Author = "Original Creator",
};
var (function, stored) = BuildFunction(existing);
// Bob edits Alice's rule; neither Bob nor the spoofed body author may win.
var req = BuildRequest(
Principal(("tid", TenantId), ("name", "Bob Editor")), SpoofedPayload());
var response = await function.UpdateRule(req, RuleId);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var written = Assert.Single(stored);
Assert.Equal("Original Creator", written.Author);
}
}
@@ -0,0 +1,425 @@
using System.Net;
using System.Security.Claims;
using System.Text;
using AutopilotMonitor.Functions.Functions.Annotations;
using AutopilotMonitor.Functions.Helpers;
using AutopilotMonitor.Shared;
using AutopilotMonitor.Shared.DataAccess;
using AutopilotMonitor.Shared.Models;
using Azure.Core.Serialization;
using Microsoft.Azure.Functions.Worker;
using Microsoft.Azure.Functions.Worker.Http;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
using Moq;
using Newtonsoft.Json;
using Xunit;
namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// Pins the session-annotation authorization surface: the per-lane write matrix
/// (<see cref="UpsertSessionAnnotationFunction.IsLaneWritableByCaller"/>), the GA-lane
/// read filter (<see cref="GetSessionAnnotationsFunction.FilterLanesForCaller"/>), and —
/// endpoint-level, through the real request pipeline — that author identity is stamped
/// from the JWT and never from the body (same contract as the gather-rule PUT-upserts).
/// </summary>
public class SessionAnnotationFunctionsTests
{
private const string TenantId = "a1b2c3d4-e5f6-7890-abcd-ef1234567890";
private const string SessionId = "22222222-2222-2222-2222-222222222222";
// ── per-lane write matrix ───────────────────────────────────────────────
// Callers reaching the function already passed TenantAdminOrOperator (tenant
// Admin/Operator, or GA via bypass) — the matrix re-gates per lane.
[Theory]
// operator lane: Operator or Tenant Admin; GA (cross-tenant: not tenant admin) may not
[InlineData(AnnotationLanes.Operator, "Operator", false, false, true)]
[InlineData(AnnotationLanes.Operator, "Admin", true, false, true)]
[InlineData(AnnotationLanes.Operator, "GlobalAdmin", false, true, false)]
[InlineData(AnnotationLanes.Operator, "Viewer", false, false, false)]
// tenantadmin lane: Tenant Admin only
[InlineData(AnnotationLanes.TenantAdmin, "Admin", true, false, true)]
[InlineData(AnnotationLanes.TenantAdmin, "Operator", false, false, false)]
[InlineData(AnnotationLanes.TenantAdmin, "GlobalAdmin", false, true, false)]
// globaladmin lane: Global Admin only
[InlineData(AnnotationLanes.GlobalAdmin, "GlobalAdmin", false, true, true)]
[InlineData(AnnotationLanes.GlobalAdmin, "Admin", true, false, false)]
[InlineData(AnnotationLanes.GlobalAdmin, "Operator", false, false, false)]
// unknown lane: never writable
[InlineData("someotherlane", "GlobalAdmin", true, true, false)]
public void IsLaneWritableByCaller_matrix(
string lane, string userRole, bool isTenantAdmin, bool isGlobalAdmin, bool expected)
{
Assert.Equal(expected,
UpsertSessionAnnotationFunction.IsLaneWritableByCaller(lane, userRole, isTenantAdmin, isGlobalAdmin));
}
[Fact]
public void IsLaneWritableByCaller_ga_who_is_also_own_tenant_admin_may_write_tenant_lanes()
{
// A GA annotating a session of their OWN tenant (IsTenantAdmin=true there) may use
// the tenant lanes — the GA-only restriction applies to foreign tenants, where
// IsTenantAdmin is false.
Assert.True(UpsertSessionAnnotationFunction.IsLaneWritableByCaller(
AnnotationLanes.TenantAdmin, "GlobalAdmin", isTenantAdmin: true, isGlobalAdmin: true));
}
// ── GA-lane read filter ─────────────────────────────────────────────────
private static List<SessionAnnotation> AllLanes() => new()
{
new SessionAnnotation { Lane = AnnotationLanes.Operator, SessionId = SessionId },
new SessionAnnotation { Lane = AnnotationLanes.TenantAdmin, SessionId = SessionId },
new SessionAnnotation { Lane = AnnotationLanes.GlobalAdmin, SessionId = SessionId },
};
[Fact]
public void FilterLanesForCaller_hides_globaladmin_lane_without_global_scope()
{
var visible = GetSessionAnnotationsFunction.FilterLanesForCaller(AllLanes(), hasGlobalScope: false);
Assert.Equal(2, visible.Count);
Assert.DoesNotContain(visible, a => a.Lane == AnnotationLanes.GlobalAdmin);
}
[Fact]
public void FilterLanesForCaller_returns_all_lanes_for_global_scope()
{
var visible = GetSessionAnnotationsFunction.FilterLanesForCaller(AllLanes(), hasGlobalScope: true);
Assert.Equal(3, visible.Count);
}
// ── global list: GA-lane exclusion follows global scope ─────────────────
// The route is GlobalReadOrAdmin, but the delegated ("MSP") read rescue admits a
// delegated caller on its managed ?tenantId= path — the platform-internal
// globaladmin lane must be excluded server-side for anyone without global scope.
[Theory]
[InlineData(true, false)]
[InlineData(false, true)]
public async Task GlobalList_derives_globaladmin_lane_exclusion_from_global_scope(
bool hasGlobalScope, bool expectedExclude)
{
var annotationRepo = new Mock<ISessionAnnotationRepository>(MockBehavior.Loose);
bool? capturedExclude = null;
annotationRepo
.Setup(r => r.QueryPageAsync(
It.IsAny<string?>(), It.IsAny<string?>(), It.IsAny<string?>(), It.IsAny<string?>(),
It.IsAny<DateTime?>(), It.IsAny<DateTime?>(), It.IsAny<int>(), It.IsAny<string?>(),
It.IsAny<bool>()))
.Callback<string?, string?, string?, string?, DateTime?, DateTime?, int, string?, bool>(
(_, _, _, _, _, _, _, _, exclude) => capturedExclude = exclude)
.ReturnsAsync((new List<SessionAnnotation>(), (string?)null));
var function = new ListSessionAnnotationsFunction(
NullLogger<ListSessionAnnotationsFunction>.Instance, annotationRepo.Object);
var requestCtx = new RequestContext
{
TenantId = TenantId,
TargetTenantId = TenantId,
UserPrincipalName = "reader@contoso.com",
IsGlobalAdmin = hasGlobalScope,
};
var req = BuildRequest(Principal(("tid", TenantId)), requestCtx, new { });
Mock.Get(req).SetupGet(r => r.Url)
.Returns(new Uri("https://localhost/api/global/session-annotations"));
var res = await function.Run(req);
Assert.Equal(HttpStatusCode.OK, res.StatusCode);
Assert.Equal(expectedExclude, capturedExclude);
}
// ── endpoint-level tests (fake HttpRequestData, same harness as
// GatherRulesFunctionAuthorEndpointTests) ──────────────────────────────
private sealed class FakeHttpResponseData : HttpResponseData
{
public FakeHttpResponseData(FunctionContext context) : base(context) { }
public override HttpStatusCode StatusCode { get; set; } = HttpStatusCode.OK;
public override HttpHeadersCollection Headers { get; set; } = new();
public override Stream Body { get; set; } = new MemoryStream();
public override HttpCookies Cookies { get; } = new Mock<HttpCookies>().Object;
}
private static FunctionContext BuildContext(ClaimsPrincipal? principal, RequestContext requestContext)
{
var services = new ServiceCollection();
services.AddOptions();
services.Configure<WorkerOptions>(o => o.Serializer = new JsonObjectSerializer());
var provider = services.BuildServiceProvider();
var items = new Dictionary<object, object> { ["RequestContext"] = requestContext };
if (principal != null)
{
items["ClaimsPrincipal"] = principal;
}
var context = new Mock<FunctionContext>();
context.SetupGet(c => c.Items).Returns(items);
context.SetupGet(c => c.InstanceServices).Returns(provider);
return context.Object;
}
private static HttpRequestData BuildRequest(
ClaimsPrincipal? principal, RequestContext requestContext, object body)
{
var context = BuildContext(principal, requestContext);
var req = new Mock<HttpRequestData>(context);
req.SetupGet(r => r.Headers).Returns(new HttpHeadersCollection());
req.SetupGet(r => r.Body).Returns(
new MemoryStream(Encoding.UTF8.GetBytes(JsonConvert.SerializeObject(body))));
req.Setup(r => r.CreateResponse()).Returns(() => new FakeHttpResponseData(context));
return req.Object;
}
private static ClaimsPrincipal Principal(params (string Type, string Value)[] claims)
=> new(new ClaimsIdentity(claims.Select(c => new Claim(c.Type, c.Value)), "TestAuth"));
private static RequestContext TenantAdminContext() => new()
{
TenantId = TenantId,
TargetTenantId = TenantId,
UserPrincipalName = "alice@contoso.com",
IsTenantAdmin = true,
UserRole = Constants.TenantRoles.Admin,
};
private sealed class Harness
{
public UpsertSessionAnnotationFunction Function = default!;
public Mock<ISessionAnnotationRepository> AnnotationRepo = default!;
public List<SessionAnnotation> Stored = default!;
}
private static Harness BuildFunction(
SessionAnnotation? existing = null, List<RuleResult>? ruleResults = null)
{
var annotationRepo = new Mock<ISessionAnnotationRepository>(MockBehavior.Loose);
var stored = new List<SessionAnnotation>();
annotationRepo.Setup(r => r.GetAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>()))
.ReturnsAsync(existing);
annotationRepo.Setup(r => r.UpsertAsync(It.IsAny<SessionAnnotation>()))
.Callback<SessionAnnotation>(stored.Add)
.Returns(Task.CompletedTask);
var sessionRepo = new Mock<ISessionRepository>(MockBehavior.Loose);
sessionRepo.Setup(r => r.GetSessionAsync(It.IsAny<string>(), It.IsAny<string>()))
.ReturnsAsync(new SessionSummary { SessionId = SessionId, TenantId = TenantId });
sessionRepo.Setup(r => r.FindSessionTenantIdAsync(SessionId)).ReturnsAsync(TenantId);
var ruleRepo = new Mock<IRuleRepository>(MockBehavior.Loose);
ruleRepo.Setup(r => r.GetRuleResultsAsync(It.IsAny<string>(), It.IsAny<string>()))
.ReturnsAsync(ruleResults ?? new List<RuleResult>());
var maintenanceRepo = new Mock<IMaintenanceRepository>(MockBehavior.Loose);
return new Harness
{
Function = new UpsertSessionAnnotationFunction(
NullLogger<UpsertSessionAnnotationFunction>.Instance,
annotationRepo.Object, sessionRepo.Object, ruleRepo.Object, maintenanceRepo.Object),
AnnotationRepo = annotationRepo,
Stored = stored,
};
}
[Fact]
public async Task Upsert_stamps_author_from_jwt_not_from_body()
{
var h = BuildFunction();
// Body tries to smuggle author fields — the function must never read them.
var req = BuildRequest(
Principal(("tid", TenantId), ("name", "Alice Admin"), ("upn", "alice@contoso.com")),
TenantAdminContext(),
new
{
verdict = AnnotationVerdicts.RootCauseConfirmed,
note = "checked on device",
authorUpn = "spoofed@evil.example",
authorDisplayName = "Spoofed Author",
createdByUpn = "spoofed@evil.example",
});
var response = await h.Function.Run(req, SessionId, AnnotationLanes.TenantAdmin);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var written = Assert.Single(h.Stored);
Assert.Equal("Alice Admin", written.AuthorDisplayName);
Assert.Equal("alice@contoso.com", written.AuthorUpn);
Assert.Equal("alice@contoso.com", written.CreatedByUpn);
}
[Fact]
public async Task Upsert_true_update_preserves_original_creator_and_createdAt()
{
var createdAt = new DateTime(2026, 7, 1, 8, 0, 0, DateTimeKind.Utc);
var h = BuildFunction(existing: new SessionAnnotation
{
TenantId = TenantId,
SessionId = SessionId,
Lane = AnnotationLanes.TenantAdmin,
CreatedByUpn = "bob@contoso.com",
CreatedAtUtc = createdAt,
});
var req = BuildRequest(
Principal(("tid", TenantId), ("name", "Alice Admin"), ("upn", "alice@contoso.com")),
TenantAdminContext(),
new { verdict = AnnotationVerdicts.AnalysisWrong });
var response = await h.Function.Run(req, SessionId, AnnotationLanes.TenantAdmin);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var written = Assert.Single(h.Stored);
Assert.Equal("bob@contoso.com", written.CreatedByUpn); // first writer survives
Assert.Equal(createdAt, written.CreatedAtUtc);
Assert.Equal("alice@contoso.com", written.AuthorUpn); // last editor stamped
}
[Fact]
public async Task Upsert_snapshots_fired_rule_ids()
{
var h = BuildFunction(ruleResults: new List<RuleResult>
{
new RuleResult { SessionId = SessionId, RuleId = "ANALYZE-ESP-001" },
new RuleResult { SessionId = SessionId, RuleId = "ANALYZE-ESP-001" }, // duplicate collapses
new RuleResult { SessionId = SessionId, RuleId = "ANALYZE-CORR-003" },
});
var req = BuildRequest(
Principal(("tid", TenantId), ("name", "Alice Admin"), ("upn", "alice@contoso.com")),
TenantAdminContext(),
new { verdict = AnnotationVerdicts.RootCauseConfirmed });
await h.Function.Run(req, SessionId, AnnotationLanes.TenantAdmin);
var written = Assert.Single(h.Stored);
Assert.Equal(new[] { "ANALYZE-ESP-001", "ANALYZE-CORR-003" }, written.RuleIds);
}
[Fact]
public async Task Upsert_empty_body_clears_the_lane()
{
var h = BuildFunction();
var req = BuildRequest(
Principal(("tid", TenantId), ("name", "Alice Admin"), ("upn", "alice@contoso.com")),
TenantAdminContext(),
new { verdict = (string?)null, note = "" });
var response = await h.Function.Run(req, SessionId, AnnotationLanes.TenantAdmin);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
Assert.Empty(h.Stored);
h.AnnotationRepo.Verify(
r => r.DeleteAsync(TenantId, SessionId, AnnotationLanes.TenantAdmin), Times.Once);
}
[Fact]
public async Task Upsert_unknown_verdict_is_rejected()
{
var h = BuildFunction();
var req = BuildRequest(
Principal(("tid", TenantId), ("upn", "alice@contoso.com")),
TenantAdminContext(),
new { verdict = "totally_made_up" });
var response = await h.Function.Run(req, SessionId, AnnotationLanes.TenantAdmin);
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
Assert.Empty(h.Stored);
}
[Fact]
public async Task Upsert_note_over_cap_is_rejected()
{
var h = BuildFunction();
var req = BuildRequest(
Principal(("tid", TenantId), ("upn", "alice@contoso.com")),
TenantAdminContext(),
new { note = new string('x', SessionAnnotation.MaxNoteLength + 1) });
var response = await h.Function.Run(req, SessionId, AnnotationLanes.TenantAdmin);
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
Assert.Empty(h.Stored);
}
[Fact]
public async Task Upsert_forbids_lane_outside_caller_matrix()
{
var h = BuildFunction();
var operatorContext = new RequestContext
{
TenantId = TenantId,
TargetTenantId = TenantId,
UserPrincipalName = "op@contoso.com",
IsTenantAdmin = false,
UserRole = Constants.TenantRoles.Operator,
};
var req = BuildRequest(
Principal(("tid", TenantId), ("upn", "op@contoso.com")),
operatorContext,
new { verdict = AnnotationVerdicts.RootCauseConfirmed });
var response = await h.Function.Run(req, SessionId, AnnotationLanes.TenantAdmin);
Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
Assert.Empty(h.Stored);
}
[Fact]
public async Task Upsert_ga_with_own_tenant_admin_role_cannot_write_foreign_tenant_lanes()
{
// The session belongs to TenantId (foreign); the GA is Admin of their OWN tenant.
// Tenant-role lanes bind to the caller's own tenant, so this must 403 — otherwise a
// GA's home-tenant admin role would leak into every other tenant's lanes.
var h = BuildFunction();
var gaContext = new RequestContext
{
TenantId = "99999999-9999-9999-9999-999999999999",
TargetTenantId = "99999999-9999-9999-9999-999999999999",
UserPrincipalName = "ga@fabrikam.com",
IsGlobalAdmin = true,
IsTenantAdmin = true, // admin of the HOME tenant, not of TenantId
UserRole = Constants.TenantRoles.Admin,
};
var req = BuildRequest(
Principal(("tid", gaContext.TenantId), ("upn", "ga@fabrikam.com")),
gaContext,
new { verdict = AnnotationVerdicts.RootCauseConfirmed });
var response = await h.Function.Run(req, SessionId, AnnotationLanes.TenantAdmin);
Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
Assert.Empty(h.Stored);
}
[Fact]
public async Task Upsert_ga_writes_globaladmin_lane_on_foreign_tenant_session()
{
var h = BuildFunction();
var gaContext = new RequestContext
{
TenantId = "99999999-9999-9999-9999-999999999999", // GA's home tenant
TargetTenantId = "99999999-9999-9999-9999-999999999999",
UserPrincipalName = "ga@fabrikam.com",
IsGlobalAdmin = true,
UserRole = "GlobalAdmin",
};
var req = BuildRequest(
Principal(("tid", gaContext.TenantId), ("name", "Gerd Admin"), ("upn", "ga@fabrikam.com")),
gaContext,
new { verdict = AnnotationVerdicts.DifferentProblem, note = "actually a network issue" });
var response = await h.Function.Run(req, SessionId, AnnotationLanes.GlobalAdmin);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var written = Assert.Single(h.Stored);
// Tenant resolved from the session index, NOT the GA's own tenant.
Assert.Equal(TenantId, written.TenantId);
Assert.Equal(AnnotationLanes.GlobalAdmin, written.Lane);
}
}
@@ -0,0 +1,165 @@
using System;
using System.Collections.Generic;
using AutopilotMonitor.Functions.DataAccess.TableStorage;
using AutopilotMonitor.Shared.Models;
using Azure.Data.Tables;
using Xunit;
namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// Round-trip pins for the SessionAnnotations Store/Map pair (memory:
/// feedback_table_storage_serialization — every new field in <see cref="SessionAnnotation"/>
/// MUST be exercised in Store+Map so silent drops surface here), plus the RowKey and
/// OData-filter builders.
/// </summary>
public class TableSessionAnnotationRepositoryTests
{
private const string TenantId = "11111111-1111-1111-1111-111111111111";
private const string SessionId = "22222222-2222-2222-2222-222222222222";
private static SessionAnnotation FullAnnotation() => new()
{
TenantId = TenantId,
SessionId = SessionId,
Lane = AnnotationLanes.TenantAdmin,
Verdict = AnnotationVerdicts.RootCauseConfirmed,
Note = "Root cause matches what we saw on the device.",
AuthorUpn = "alice@contoso.com",
AuthorDisplayName = "Alice Admin",
CreatedByUpn = "bob@contoso.com",
CreatedAtUtc = new DateTime(2026, 8, 1, 10, 30, 0, DateTimeKind.Utc),
UpdatedAtUtc = new DateTime(2026, 8, 11, 9, 15, 0, DateTimeKind.Utc),
RuleIds = new List<string> { "ANALYZE-ESP-001", "ANALYZE-CORR-003" },
};
[Fact]
public void Roundtrip_all_fields_survive_store_and_map()
{
var original = FullAnnotation();
var mapped = TableSessionAnnotationRepository.MapAnnotation(
TableSessionAnnotationRepository.StoreAnnotation(original));
Assert.Equal(original.TenantId, mapped.TenantId);
Assert.Equal(original.SessionId, mapped.SessionId);
Assert.Equal(original.Lane, mapped.Lane);
Assert.Equal(original.Verdict, mapped.Verdict);
Assert.Equal(original.Note, mapped.Note);
Assert.Equal(original.AuthorUpn, mapped.AuthorUpn);
Assert.Equal(original.AuthorDisplayName, mapped.AuthorDisplayName);
Assert.Equal(original.CreatedByUpn, mapped.CreatedByUpn);
Assert.Equal(original.CreatedAtUtc, mapped.CreatedAtUtc);
Assert.Equal(original.UpdatedAtUtc, mapped.UpdatedAtUtc);
Assert.Equal(original.RuleIds, mapped.RuleIds);
}
[Fact]
public void Roundtrip_null_verdict_and_note_stay_null()
{
var original = FullAnnotation();
original.Verdict = null;
original.Note = null;
original.RuleIds = new List<string>();
var mapped = TableSessionAnnotationRepository.MapAnnotation(
TableSessionAnnotationRepository.StoreAnnotation(original));
Assert.Null(mapped.Verdict);
Assert.Null(mapped.Note);
Assert.Empty(mapped.RuleIds);
}
[Fact]
public void Store_uses_tenant_pk_and_sessionId_lane_rk()
{
var entity = TableSessionAnnotationRepository.StoreAnnotation(FullAnnotation());
Assert.Equal(TenantId, entity.PartitionKey);
Assert.Equal($"{SessionId}_{AnnotationLanes.TenantAdmin}", entity.RowKey);
}
[Theory]
[InlineData("not json at all")]
[InlineData("{\"truncated\":")]
[InlineData("")]
[InlineData(null)]
public void Map_corrupt_or_missing_ruleIds_degrades_to_empty_list(string? corruptJson)
{
var entity = TableSessionAnnotationRepository.StoreAnnotation(FullAnnotation());
entity["RuleIdsJson"] = corruptJson;
var mapped = TableSessionAnnotationRepository.MapAnnotation(entity);
Assert.Empty(mapped.RuleIds);
}
[Fact]
public void Map_tolerates_minimal_legacy_entity()
{
// A row carrying only PK/RK (e.g. hand-repaired) must map without throwing.
var entity = new TableEntity(TenantId, $"{SessionId}_{AnnotationLanes.Operator}");
var mapped = TableSessionAnnotationRepository.MapAnnotation(entity);
Assert.Equal(TenantId, mapped.TenantId);
Assert.Equal(string.Empty, mapped.SessionId);
Assert.Null(mapped.Verdict);
Assert.Null(mapped.Note);
Assert.Empty(mapped.RuleIds);
}
// ── query filter builder ────────────────────────────────────────────────
[Fact]
public void BuildQueryFilter_no_filters_returns_null()
{
Assert.Null(TableSessionAnnotationRepository.BuildQueryFilter(null, null, null, null, null));
}
[Fact]
public void BuildQueryFilter_combines_all_clauses_with_and()
{
var filter = TableSessionAnnotationRepository.BuildQueryFilter(
TenantId, AnnotationLanes.GlobalAdmin, AnnotationVerdicts.AnalysisWrong,
new DateTime(2026, 8, 1, 0, 0, 0, DateTimeKind.Utc),
new DateTime(2026, 8, 11, 0, 0, 0, DateTimeKind.Utc));
Assert.Equal(
$"PartitionKey eq '{TenantId}'" +
" and Lane eq 'globaladmin'" +
" and Verdict eq 'analysis_wrong'" +
" and UpdatedAtUtc ge datetime'2026-08-01T00:00:00Z'" +
" and UpdatedAtUtc lt datetime'2026-08-11T00:00:00Z'",
filter);
}
[Fact]
public void BuildQueryFilter_escapes_single_quotes()
{
var filter = TableSessionAnnotationRepository.BuildQueryFilter("ten'ant", null, null, null, null);
Assert.Equal("PartitionKey eq 'ten''ant'", filter);
}
[Fact]
public void BuildQueryFilter_excludes_globaladmin_lane_for_tenant_scope()
{
var filter = TableSessionAnnotationRepository.BuildQueryFilter(
TenantId, null, null, null, null, excludeGlobalAdminLane: true);
Assert.Equal($"PartitionKey eq '{TenantId}' and Lane ne 'globaladmin'", filter);
}
[Fact]
public void BuildQueryFilter_explicit_globaladmin_lane_with_exclusion_contradicts_to_empty()
{
// A tenant caller explicitly asking for lane=globaladmin gets a self-contradicting
// filter (eq + ne on the same value) — structurally an empty result, never a leak.
var filter = TableSessionAnnotationRepository.BuildQueryFilter(
TenantId, AnnotationLanes.GlobalAdmin, null, null, null, excludeGlobalAdminLane: true);
Assert.Contains("Lane eq 'globaladmin'", filter);
Assert.Contains("Lane ne 'globaladmin'", filter);
}
}
@@ -55,12 +55,6 @@
<PackageReference Include="Newtonsoft.Json" Version="13.0.4" />
<PackageReference Include="Microsoft.Identity.Web" Version="4.3.0" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.10" />
<!-- Security pins: Microsoft.Identity.Web 4.3.0 pulls these transitively at 10.0.0, which carries
known advisories (GHSA-9mv3-2cwr-p262 DataProtection; GHSA-37gx-xxp4-5rgx and siblings on
Cryptography.Xml). Force the patched 10.0.10 servicing build. Remove once Identity.Web ships
a dependency floor past the fix. -->
<PackageReference Include="Microsoft.AspNetCore.DataProtection" Version="10.0.10" />
<PackageReference Include="System.Security.Cryptography.Xml" Version="10.0.10" />
<PackageReference Include="Microsoft.Extensions.Http.Polly" Version="8.0.25" />
<PackageReference Include="Resend" Version="0.2.1" />
</ItemGroup>
@@ -31,6 +31,7 @@ namespace AutopilotMonitor.Functions.DataAccess
services.AddSingleton<IAdminRepository, TableAdminRepository>();
services.AddSingleton<IConfigRepository, TableConfigRepository>();
services.AddSingleton<IFeedbackRepository, TableFeedbackRepository>();
services.AddSingleton<ISessionAnnotationRepository, TableSessionAnnotationRepository>();
services.AddSingleton<IBootstrapRepository, TableBootstrapRepository>();
services.AddSingleton<INotificationRepository, TableNotificationRepository>();
services.AddSingleton<ITenantNotificationRepository, TableTenantNotificationRepository>();
@@ -0,0 +1,243 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Text.Json;
using System.Threading.Tasks;
using AutopilotMonitor.Functions.Pagination;
using AutopilotMonitor.Functions.Services;
using AutopilotMonitor.Shared;
using AutopilotMonitor.Shared.DataAccess;
using AutopilotMonitor.Shared.Models;
using Azure;
using Azure.Data.Tables;
using Microsoft.Extensions.Logging;
namespace AutopilotMonitor.Functions.DataAccess.TableStorage
{
/// <summary>
/// Table-storage implementation of <see cref="ISessionAnnotationRepository"/>.
/// Backed by the dedicated <c>SessionAnnotations</c> table:
/// PK = tenantId, RK = <c>{sessionId}_{lane}</c>.
/// </summary>
public class TableSessionAnnotationRepository : ISessionAnnotationRepository
{
// Client-side ruleId filtering can shrink an Azure page below pageSize; the
// back-fill loop keeps fetching until enough matches accumulate, capped here so
// a pathological filter can never turn one request into an unbounded scan.
internal const int MaxBackfillRoundTrips = 10;
private readonly TableClient _tableClient;
private readonly ILogger<TableSessionAnnotationRepository> _logger;
public TableSessionAnnotationRepository(
TableStorageService storage, ILogger<TableSessionAnnotationRepository> logger)
{
_tableClient = storage.GetTableClient(Constants.TableNames.SessionAnnotations);
_logger = logger;
}
public async Task<SessionAnnotation?> GetAsync(string tenantId, string sessionId, string lane)
{
try
{
var entity = await _tableClient.GetEntityAsync<TableEntity>(
tenantId, BuildRowKey(sessionId, lane));
return MapAnnotation(entity.Value);
}
catch (RequestFailedException ex) when (ex.Status == 404)
{
return null;
}
catch (Exception ex)
{
_logger.LogError(ex,
"Error loading annotation {SessionId}/{Lane} for tenant {TenantId}",
sessionId, lane, tenantId);
throw;
}
}
public async Task<List<SessionAnnotation>> GetForSessionAsync(string tenantId, string sessionId)
{
try
{
// '~' (0x7E) sorts after '_' (0x5F) and every lane letter: upper-bounds all
// "{sessionId}_{lane}" keys without matching a longer sessionId prefix.
var filter =
$"PartitionKey eq '{Escape(tenantId)}'" +
$" and RowKey ge '{Escape(sessionId)}_'" +
$" and RowKey lt '{Escape(sessionId)}_~'";
var results = new List<SessionAnnotation>();
await foreach (var entity in _tableClient.QueryAsync<TableEntity>(filter: filter))
{
results.Add(MapAnnotation(entity));
}
return results;
}
catch (Exception ex)
{
_logger.LogError(ex,
"Error loading annotations for session {SessionId}, tenant {TenantId}",
sessionId, tenantId);
throw;
}
}
public async Task UpsertAsync(SessionAnnotation annotation)
{
try
{
await _tableClient.UpsertEntityAsync(StoreAnnotation(annotation), TableUpdateMode.Replace);
}
catch (Exception ex)
{
_logger.LogError(ex,
"Error saving annotation {SessionId}/{Lane} for tenant {TenantId}",
annotation.SessionId, annotation.Lane, annotation.TenantId);
throw;
}
}
public async Task DeleteAsync(string tenantId, string sessionId, string lane)
{
try
{
await _tableClient.DeleteEntityAsync(tenantId, BuildRowKey(sessionId, lane));
}
catch (RequestFailedException ex) when (ex.Status == 404)
{
// Missing row is a no-op — clearing an annotation twice must not fail.
}
catch (Exception ex)
{
_logger.LogError(ex,
"Error deleting annotation {SessionId}/{Lane} for tenant {TenantId}",
sessionId, lane, tenantId);
throw;
}
}
public async Task<(List<SessionAnnotation> Items, string? NextRawToken)> QueryPageAsync(
string? tenantId,
string? lane,
string? verdict,
string? ruleId,
DateTime? dateFrom,
DateTime? dateTo,
int pageSize,
string? continuation,
bool excludeGlobalAdminLane = false)
{
var filter = BuildQueryFilter(tenantId, lane, verdict, dateFrom, dateTo, excludeGlobalAdminLane);
var items = new List<SessionAnnotation>();
var token = continuation;
// ruleId cannot be pushed into OData (substring match on a JSON column), so it
// filters client-side. Back-fill whole Azure pages until pageSize matches
// accumulate or the scan is exhausted, so filtered-out rows never consume page
// budget (lesson: filter-after-pagination empties the list).
for (var round = 0; round < MaxBackfillRoundTrips; round++)
{
var (page, nextToken) = await AzureTablesPaginator.FetchPageAsync<TableEntity>(
_tableClient, filter, pageSize, token);
foreach (var entity in page)
{
var annotation = MapAnnotation(entity);
if (ruleId != null &&
!annotation.RuleIds.Contains(ruleId, StringComparer.OrdinalIgnoreCase))
{
continue;
}
items.Add(annotation);
}
token = nextToken;
if (token == null || items.Count >= pageSize)
{
break;
}
}
return (items, token);
}
internal static string BuildRowKey(string sessionId, string lane) => $"{sessionId}_{lane}";
internal static string? BuildQueryFilter(
string? tenantId, string? lane, string? verdict, DateTime? dateFrom, DateTime? dateTo,
bool excludeGlobalAdminLane = false)
{
var sb = new StringBuilder();
void Append(string clause)
{
if (sb.Length > 0) sb.Append(" and ");
sb.Append(clause);
}
if (!string.IsNullOrEmpty(tenantId)) Append($"PartitionKey eq '{Escape(tenantId!)}'");
if (!string.IsNullOrEmpty(lane)) Append($"Lane eq '{Escape(lane!)}'");
// Server-side, so hidden rows never consume page budget; combined with an explicit
// lane=globaladmin filter this contradicts to an empty result — fail-safe by shape.
if (excludeGlobalAdminLane) Append($"Lane ne '{AnnotationLanes.GlobalAdmin}'");
if (!string.IsNullOrEmpty(verdict)) Append($"Verdict eq '{Escape(verdict!)}'");
if (dateFrom.HasValue) Append($"UpdatedAtUtc ge datetime'{dateFrom.Value.ToUniversalTime():yyyy-MM-ddTHH:mm:ssZ}'");
if (dateTo.HasValue) Append($"UpdatedAtUtc lt datetime'{dateTo.Value.ToUniversalTime():yyyy-MM-ddTHH:mm:ssZ}'");
return sb.Length == 0 ? null : sb.ToString();
}
private static string Escape(string value) => value.Replace("'", "''");
// ── Store / Map (memory: feedback_table_storage_serialization) ─────────
// internal static: pinned by round-trip unit tests — every property must
// survive Store→Map, so any new field lands in BOTH methods plus the test.
internal static TableEntity StoreAnnotation(SessionAnnotation a) =>
new(a.TenantId, BuildRowKey(a.SessionId, a.Lane))
{
["SessionId"] = a.SessionId,
["Lane"] = a.Lane,
["Verdict"] = a.Verdict,
["Note"] = a.Note,
["AuthorUpn"] = a.AuthorUpn,
["AuthorDisplayName"] = a.AuthorDisplayName,
["CreatedByUpn"] = a.CreatedByUpn,
["CreatedAtUtc"] = EnsureUtc(a.CreatedAtUtc),
["UpdatedAtUtc"] = EnsureUtc(a.UpdatedAtUtc),
["RuleIdsJson"] = JsonSerializer.Serialize(a.RuleIds),
};
internal static SessionAnnotation MapAnnotation(TableEntity e) => new()
{
TenantId = e.PartitionKey,
SessionId = e.GetString("SessionId") ?? string.Empty,
Lane = e.GetString("Lane") ?? string.Empty,
Verdict = e.GetString("Verdict"),
Note = e.GetString("Note"),
AuthorUpn = e.GetString("AuthorUpn") ?? string.Empty,
AuthorDisplayName = e.GetString("AuthorDisplayName") ?? string.Empty,
CreatedByUpn = e.GetString("CreatedByUpn") ?? string.Empty,
CreatedAtUtc = e.GetDateTime("CreatedAtUtc") ?? DateTime.MinValue,
UpdatedAtUtc = e.GetDateTime("UpdatedAtUtc") ?? DateTime.MinValue,
RuleIds = DeserializeRuleIds(e.GetString("RuleIdsJson")),
};
private static List<string> DeserializeRuleIds(string? json)
{
if (string.IsNullOrEmpty(json)) return new List<string>();
try
{
return JsonSerializer.Deserialize<List<string>>(json!) ?? new List<string>();
}
catch (JsonException)
{
return new List<string>(); // corrupt/truncated column — degrade to empty, never throw on read
}
}
private static DateTime EnsureUtc(DateTime value) =>
value.Kind == DateTimeKind.Utc ? value : DateTime.SpecifyKind(value, DateTimeKind.Utc);
}
}
@@ -0,0 +1,125 @@
using System;
using System.Collections.Generic;
using System.Linq;
using AutopilotMonitor.Functions.Helpers;
using AutopilotMonitor.Shared.DataAccess;
using AutopilotMonitor.Shared.Models;
using Microsoft.Azure.Functions.Worker;
using Microsoft.Azure.Functions.Worker.Http;
using Microsoft.Extensions.Logging;
namespace AutopilotMonitor.Functions.Functions.Annotations
{
/// <summary>
/// Returns the annotations (all lanes visible to the caller) for one session.
/// The <c>globaladmin</c> lane is platform-internal and filtered out for callers
/// without global scope.
/// </summary>
public class GetSessionAnnotationsFunction
{
private readonly ILogger<GetSessionAnnotationsFunction> _logger;
private readonly ISessionAnnotationRepository _annotationRepo;
private readonly ISessionRepository _sessionRepo;
public GetSessionAnnotationsFunction(
ILogger<GetSessionAnnotationsFunction> logger,
ISessionAnnotationRepository annotationRepo,
ISessionRepository sessionRepo)
{
_logger = logger;
_annotationRepo = annotationRepo;
_sessionRepo = sessionRepo;
}
[Function("GetSessionAnnotations")]
public async Task<HttpResponseData> Run(
[HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = "sessions/{sessionId}/annotations")] HttpRequestData req,
string sessionId)
{
try
{
// Authentication + MemberRead authorization enforced by PolicyEnforcementMiddleware
var requestCtx = req.GetRequestContext();
var effectiveTenantId = requestCtx.TargetTenantId;
// Global-scope (GA or read-only GlobalReader) cross-tenant fallback: resolve
// the actual tenant upfront so the read works cross-tenant.
if (requestCtx.HasGlobalScope)
{
var resolvedTenantId = await _sessionRepo.FindSessionTenantIdAsync(sessionId);
if (resolvedTenantId != null
&& !string.Equals(resolvedTenantId, effectiveTenantId, StringComparison.OrdinalIgnoreCase))
{
effectiveTenantId = resolvedTenantId;
}
}
var annotations = await _annotationRepo.GetForSessionAsync(effectiveTenantId, sessionId);
var visible = FilterLanesForCaller(annotations, requestCtx.HasGlobalScope);
return await req.OkAsync(new
{
success = true,
sessionId,
tenantId = effectiveTenantId,
annotations = visible.Select(AnnotationWire.ToWire).ToList(),
});
}
catch (Exception ex)
{
_logger.LogError(ex, "Error fetching annotations for session {SessionId}", sessionId);
var errorResponse = req.CreateResponse(System.Net.HttpStatusCode.InternalServerError);
await errorResponse.WriteAsJsonAsync(new { success = false, message = "Internal server error." });
return errorResponse;
}
}
/// <summary>
/// The globaladmin lane is a platform-internal quality label — callers without
/// global scope (tenant members and delegated admins) never see it.
/// </summary>
internal static List<SessionAnnotation> FilterLanesForCaller(
List<SessionAnnotation> annotations, bool hasGlobalScope)
{
if (hasGlobalScope) return annotations;
return annotations
.Where(a => !string.Equals(a.Lane, AnnotationLanes.GlobalAdmin, StringComparison.OrdinalIgnoreCase))
.ToList();
}
}
/// <summary>
/// Explicit camelCase wire shape shared by the annotation endpoints so the JSON
/// contract does not depend on serializer naming configuration.
/// </summary>
internal static class AnnotationWire
{
internal static object ToWire(SessionAnnotation a) => new
{
lane = a.Lane,
verdict = a.Verdict,
note = a.Note,
authorUpn = a.AuthorUpn,
authorDisplayName = a.AuthorDisplayName,
createdByUpn = a.CreatedByUpn,
createdAtUtc = a.CreatedAtUtc,
updatedAtUtc = a.UpdatedAtUtc,
ruleIds = a.RuleIds,
};
internal static object ToWireWithScope(SessionAnnotation a) => new
{
tenantId = a.TenantId,
sessionId = a.SessionId,
lane = a.Lane,
verdict = a.Verdict,
note = a.Note,
authorUpn = a.AuthorUpn,
authorDisplayName = a.AuthorDisplayName,
createdByUpn = a.CreatedByUpn,
createdAtUtc = a.CreatedAtUtc,
updatedAtUtc = a.UpdatedAtUtc,
ruleIds = a.RuleIds,
};
}
}
@@ -0,0 +1,102 @@
using System;
using System.Linq;
using System.Net;
using System.Web;
using AutopilotMonitor.Functions.Helpers;
using AutopilotMonitor.Functions.Pagination;
using AutopilotMonitor.Shared.DataAccess;
using Microsoft.Azure.Functions.Worker;
using Microsoft.Azure.Functions.Worker.Http;
using Microsoft.Extensions.Logging;
namespace AutopilotMonitor.Functions.Functions.Annotations
{
/// <summary>
/// Cross-tenant evaluation stream over all annotation lanes (the flywheel read:
/// verdicts per rule, false-positive rates, labeled sessions). Platform scope for the
/// aggregate; a delegated ("MSP") reader reaches it only on its managed ?tenantId= path
/// and never sees the platform-internal globaladmin lane.
/// </summary>
public class ListSessionAnnotationsFunction
{
private readonly ILogger<ListSessionAnnotationsFunction> _logger;
private readonly ISessionAnnotationRepository _annotationRepo;
public ListSessionAnnotationsFunction(
ILogger<ListSessionAnnotationsFunction> logger,
ISessionAnnotationRepository annotationRepo)
{
_logger = logger;
_annotationRepo = annotationRepo;
}
[Function("ListSessionAnnotations")]
public async Task<HttpResponseData> Run(
[HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = "global/session-annotations")] HttpRequestData req)
{
try
{
// Authentication + GlobalReadOrAdmin authorization enforced by PolicyEnforcementMiddleware
var requestCtx = req.GetRequestContext();
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;
}
}
// Not only GA/Reader reach this route: the delegated ("MSP") read rescue admits a
// delegated caller on its managed ?tenantId= path. The platform-internal globaladmin
// lane stays global-scope-only, so it is excluded for everyone else.
var (items, nextRawToken) = await _annotationRepo.QueryPageAsync(
parsed.FilterTenantId, parsed.Lane, parsed.Verdict, parsed.RuleId,
parsed.DateFrom, parsed.DateTo, parsed.PageSize, azureToken,
excludeGlobalAdminLane: !requestCtx.HasGlobalScope);
string? nextLink = null;
if (!string.IsNullOrEmpty(nextRawToken))
{
var wireToken = SessionAnnotationsPagination.EncodeContinuation(
parsed, callerTenantId, nextRawToken!);
nextLink = SessionAnnotationsPagination.BuildNextLink(parsed, wireToken);
}
return await req.OkAsync(new
{
success = true,
count = items.Count,
annotations = items.Select(AnnotationWire.ToWireWithScope).ToList(),
nextLink,
});
}
catch (Exception ex)
{
_logger.LogError(ex, "Error listing session annotations");
var errorResponse = req.CreateResponse(HttpStatusCode.InternalServerError);
await errorResponse.WriteAsJsonAsync(new { success = false, message = "Internal server error." });
return errorResponse;
}
}
}
}
@@ -0,0 +1,121 @@
using System;
using System.Linq;
using System.Net;
using System.Web;
using AutopilotMonitor.Functions.Helpers;
using AutopilotMonitor.Functions.Pagination;
using AutopilotMonitor.Shared.DataAccess;
using Microsoft.Azure.Functions.Worker;
using Microsoft.Azure.Functions.Worker.Http;
using Microsoft.Extensions.Logging;
namespace AutopilotMonitor.Functions.Functions.Annotations
{
/// <summary>
/// Tenant-scoped annotation list — backs the portal's Annotations overview page
/// ("which sessions has my team already judged?"). Same filter surface as the global
/// evaluation stream, always bound to the middleware-validated target tenant. The
/// platform-internal globaladmin lane is excluded in the OData query for callers
/// without global scope, so hidden rows never consume page budget.
/// </summary>
public class ListTenantSessionAnnotationsFunction
{
private readonly ILogger<ListTenantSessionAnnotationsFunction> _logger;
private readonly ISessionAnnotationRepository _annotationRepo;
public ListTenantSessionAnnotationsFunction(
ILogger<ListTenantSessionAnnotationsFunction> logger,
ISessionAnnotationRepository annotationRepo)
{
_logger = logger;
_annotationRepo = annotationRepo;
}
// Route lives under the /api/sessions prefix so the platform cert-exclusion list
// (Azure-portal-only config) needs no new entry — same convention as
// /api/diagnostics/files. Three segments on purpose: a two-segment literal
// ("sessions/annotations") would be eaten by the sibling "sessions/{sessionId}"
// template (see the /api/stats/sessions incident note in lib/api.ts).
[Function("ListTenantSessionAnnotations")]
public async Task<HttpResponseData> Run(
[HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = "sessions/annotations/list")] HttpRequestData req)
{
try
{
// Authentication + MemberRead authorization enforced by PolicyEnforcementMiddleware
var requestCtx = req.GetRequestContext();
var callerTenantId = TenantHelper.GetTenantId(req);
var query = HttpUtility.ParseQueryString(req.Url.Query ?? string.Empty);
var parsedRaw = SessionAnnotationsPagination.ParseQuery(query);
if (parsedRaw.Error != null)
{
var bad = req.CreateResponse(HttpStatusCode.BadRequest);
await bad.WriteAsJsonAsync(new { success = false, message = parsedRaw.Error });
return bad;
}
// The tenant filter is never caller-chosen here: TargetTenantId is the
// middleware-validated scope (own tenant; the ?tenantId drill-in is validated
// there too). A body/query value can never widen it.
var parsed = new SessionAnnotationsPagination.Parsed
{
FilterTenantId = requestCtx.TargetTenantId,
Lane = parsedRaw.Lane,
Verdict = parsedRaw.Verdict,
RuleId = parsedRaw.RuleId,
DateFrom = parsedRaw.DateFrom,
DateTo = parsedRaw.DateTo,
PageSize = parsedRaw.PageSize,
Continuation = parsedRaw.Continuation,
};
string? azureToken = null;
if (parsed.Continuation != null)
{
if (!SessionAnnotationsPagination.TryAcceptContinuation(
parsed, callerTenantId, out azureToken, out var rejectReason))
{
_logger.LogWarning("ListTenantSessionAnnotations: continuation rejected ({Reason})", rejectReason);
var bad = req.CreateResponse(HttpStatusCode.BadRequest);
await bad.WriteAsJsonAsync(new
{
success = false,
message = $"Invalid continuation token ({rejectReason}). Restart pagination from the first page.",
});
return bad;
}
}
var (items, nextRawToken) = await _annotationRepo.QueryPageAsync(
parsed.FilterTenantId, parsed.Lane, parsed.Verdict, parsed.RuleId,
parsed.DateFrom, parsed.DateTo, parsed.PageSize, azureToken,
excludeGlobalAdminLane: !requestCtx.HasGlobalScope);
string? nextLink = null;
if (!string.IsNullOrEmpty(nextRawToken))
{
var wireToken = SessionAnnotationsPagination.EncodeContinuation(
parsed, callerTenantId, nextRawToken!);
nextLink = SessionAnnotationsPagination.BuildNextLink(
parsed, wireToken, SessionAnnotationsPagination.TenantBasePath);
}
return await req.OkAsync(new
{
success = true,
count = items.Count,
annotations = items.Select(AnnotationWire.ToWireWithScope).ToList(),
nextLink,
});
}
catch (Exception ex)
{
_logger.LogError(ex, "Error listing tenant session annotations");
var errorResponse = req.CreateResponse(HttpStatusCode.InternalServerError);
await errorResponse.WriteAsJsonAsync(new { success = false, message = "Internal server error." });
return errorResponse;
}
}
}
}
@@ -0,0 +1,274 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Net;
using AutopilotMonitor.Functions.Helpers;
using AutopilotMonitor.Shared;
using AutopilotMonitor.Shared.DataAccess;
using AutopilotMonitor.Shared.Models;
using Microsoft.Azure.Functions.Worker;
using Microsoft.Azure.Functions.Worker.Http;
using Microsoft.Extensions.Logging;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
namespace AutopilotMonitor.Functions.Functions.Annotations
{
/// <summary>
/// Upserts (or clears) the annotation for one session + lane.
/// Policy tier is TenantAdminOrOperator; the per-lane write matrix is re-gated here
/// via <see cref="IsLaneWritableByCaller"/> (catalog multi-kind rule). Author fields
/// are stamped from the JWT — a body-supplied author never wins.
/// </summary>
public class UpsertSessionAnnotationFunction
{
private readonly ILogger<UpsertSessionAnnotationFunction> _logger;
private readonly ISessionAnnotationRepository _annotationRepo;
private readonly ISessionRepository _sessionRepo;
private readonly IRuleRepository _ruleRepo;
private readonly IMaintenanceRepository _maintenanceRepo;
public UpsertSessionAnnotationFunction(
ILogger<UpsertSessionAnnotationFunction> logger,
ISessionAnnotationRepository annotationRepo,
ISessionRepository sessionRepo,
IRuleRepository ruleRepo,
IMaintenanceRepository maintenanceRepo)
{
_logger = logger;
_annotationRepo = annotationRepo;
_sessionRepo = sessionRepo;
_ruleRepo = ruleRepo;
_maintenanceRepo = maintenanceRepo;
}
[Function("UpsertSessionAnnotation")]
public async Task<HttpResponseData> Run(
[HttpTrigger(AuthorizationLevel.Anonymous, "put", Route = "sessions/{sessionId}/annotations/{lane}")] HttpRequestData req,
string sessionId,
string lane)
{
try
{
// Authentication + TenantAdminOrOperator authorization enforced by
// PolicyEnforcementMiddleware; the lane matrix is re-gated below.
var requestCtx = req.GetRequestContext();
var userIdentifier = TenantHelper.GetUserIdentifier(req);
var normalizedLane = lane?.ToLowerInvariant() ?? string.Empty;
if (!AnnotationLanes.All.Contains(normalizedLane))
{
return await BadRequestAsync(req,
$"lane must be one of: {string.Join(", ", AnnotationLanes.All)}");
}
// Tenant identity: non-GA callers are pinned to their middleware-validated
// tenant (never a body value — prevents horizontal escalation). Global
// Admins may annotate foreign-tenant sessions (the GA labeling flow), so
// resolve the session's actual tenant like the other session reads do.
// Resolution happens BEFORE the lane gate: tenant-role lanes must bind to
// the caller's OWN tenant, so a GA who is also an own-tenant admin cannot
// write another tenant's operator/tenantadmin lanes.
var effectiveTenantId = requestCtx.TargetTenantId;
if (requestCtx.IsGlobalAdmin)
{
var resolvedTenantId = await _sessionRepo.FindSessionTenantIdAsync(sessionId);
if (resolvedTenantId != null
&& !string.Equals(resolvedTenantId, effectiveTenantId, StringComparison.OrdinalIgnoreCase))
{
effectiveTenantId = resolvedTenantId;
}
}
var isOwnTenant = string.Equals(effectiveTenantId, requestCtx.TenantId, StringComparison.OrdinalIgnoreCase);
if (!IsLaneWritableByCaller(
normalizedLane, isOwnTenant ? requestCtx.UserRole : null,
requestCtx.IsTenantAdmin && isOwnTenant, requestCtx.IsGlobalAdmin))
{
_logger.LogWarning(
"UpsertSessionAnnotation: BLOCKED lane={Lane} for user={User} role={Role} ownTenant={OwnTenant}",
normalizedLane, userIdentifier, requestCtx.UserRole, isOwnTenant);
var forbidden = req.CreateResponse(HttpStatusCode.Forbidden);
await forbidden.WriteAsJsonAsync(new
{
success = false,
message = $"Your role does not permit writing the '{normalizedLane}' annotation lane.",
});
return forbidden;
}
// Annotations must not create junk rows for sessions that don't exist.
var session = await _sessionRepo.GetSessionAsync(effectiveTenantId, sessionId);
if (session == null)
{
var notFound = req.CreateResponse(HttpStatusCode.NotFound);
await notFound.WriteAsJsonAsync(new { success = false, message = "Session not found." });
return notFound;
}
var body = await req.ReadAsStringAsync() ?? string.Empty;
JObject json;
try
{
json = JObject.Parse(body);
}
catch (JsonException)
{
return await BadRequestAsync(req, "Invalid JSON body.");
}
var verdict = ReadOptionalString(json, "verdict")?.ToLowerInvariant();
var note = ReadOptionalString(json, "note");
if (verdict != null && !AnnotationVerdicts.All.Contains(verdict))
{
return await BadRequestAsync(req,
$"verdict must be one of: {string.Join(", ", AnnotationVerdicts.All)}");
}
if (note != null && note.Length > SessionAnnotation.MaxNoteLength)
{
return await BadRequestAsync(req,
$"note must be at most {SessionAnnotation.MaxNoteLength} characters.");
}
// Anti-spoof: author identity comes from the JWT, never from the body
// (same stamp as the rule PUT-upserts).
var authorDisplayName = TenantHelper.GetUserDisplayName(req) ?? "Autopilot Monitor";
// Both fields empty = clear the lane.
if (verdict == null && note == null)
{
await _annotationRepo.DeleteAsync(effectiveTenantId, sessionId, normalizedLane);
await LogAuditAsync(requestCtx.IsGlobalAdmin, effectiveTenantId, "DELETE",
sessionId, normalizedLane, userIdentifier, verdict: null);
_logger.LogInformation(
"Annotation cleared for session {SessionId} lane {Lane} by {User}",
sessionId, normalizedLane, userIdentifier);
return await req.OkAsync(new { success = true, deleted = true });
}
var existing = await _annotationRepo.GetAsync(effectiveTenantId, sessionId, normalizedLane);
var now = DateTime.UtcNow;
var annotation = new SessionAnnotation
{
TenantId = effectiveTenantId,
SessionId = sessionId,
Lane = normalizedLane,
Verdict = verdict,
Note = note,
AuthorUpn = userIdentifier,
AuthorDisplayName = authorDisplayName,
CreatedByUpn = existing?.CreatedByUpn ?? userIdentifier,
CreatedAtUtc = existing?.CreatedAtUtc ?? now,
UpdatedAtUtc = now,
RuleIds = await SnapshotRuleIdsAsync(effectiveTenantId, sessionId),
};
await _annotationRepo.UpsertAsync(annotation);
await LogAuditAsync(requestCtx.IsGlobalAdmin, effectiveTenantId, "UPDATE",
sessionId, normalizedLane, userIdentifier, verdict);
_logger.LogInformation(
"Annotation saved for session {SessionId} lane {Lane} by {User} (verdict={Verdict})",
sessionId, normalizedLane, userIdentifier, verdict ?? "none");
return await req.OkAsync(new
{
success = true,
annotation = AnnotationWire.ToWire(annotation),
});
}
catch (Exception ex)
{
_logger.LogError(ex, "Error saving annotation for session {SessionId} lane {Lane}", sessionId, lane);
var errorResponse = req.CreateResponse(HttpStatusCode.InternalServerError);
await errorResponse.WriteAsJsonAsync(new { success = false, message = "Internal server error." });
return errorResponse;
}
}
/// <summary>
/// Per-lane write matrix (policy tier already limited callers to tenant
/// Admin/Operator or GA):
/// operator → Operator or Tenant Admin (admins supervise operator notes)
/// tenantadmin → Tenant Admin only
/// globaladmin → Global Admin only (GA writes ONLY this lane — clean attribution;
/// a GA who is also an own-tenant admin still passes IsTenantAdmin
/// for that tenant's lanes)
/// </summary>
internal static bool IsLaneWritableByCaller(
string lane, string? userRole, bool isTenantAdmin, bool isGlobalAdmin)
{
return lane switch
{
AnnotationLanes.GlobalAdmin => isGlobalAdmin,
AnnotationLanes.TenantAdmin => isTenantAdmin,
AnnotationLanes.Operator => isTenantAdmin
|| string.Equals(userRole, Constants.TenantRoles.Operator, StringComparison.OrdinalIgnoreCase),
_ => false,
};
}
/// <summary>
/// Snapshot of the rule ids that fired for this session, denormalized onto the
/// annotation so rule-quality evaluation needs no join. Fail-soft: an empty list
/// never blocks the write.
/// </summary>
private async Task<List<string>> SnapshotRuleIdsAsync(string tenantId, string sessionId)
{
try
{
var results = await _ruleRepo.GetRuleResultsAsync(tenantId, sessionId);
return results
.Select(r => r.RuleId)
.Where(id => !string.IsNullOrEmpty(id))
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToList();
}
catch (Exception ex)
{
_logger.LogWarning(ex,
"Annotation rule-id snapshot failed for session {SessionId}; storing empty list", sessionId);
return new List<string>();
}
}
// Audit entries land in the tenant-visible audit log; GA writes are skipped so
// platform-internal labeling activity does not surface there (same convention as
// session-report submissions by GAs).
private async Task LogAuditAsync(
bool isGlobalAdmin, string tenantId, string action,
string sessionId, string lane, string userIdentifier, string? verdict)
{
if (isGlobalAdmin) return;
await _maintenanceRepo.LogAuditEntryAsync(
tenantId,
action,
"SessionAnnotation",
$"{sessionId}/{lane}",
userIdentifier,
new Dictionary<string, string>
{
{ "Action", "UpsertSessionAnnotation" },
{ "SessionId", sessionId },
{ "Lane", lane },
{ "Verdict", verdict ?? string.Empty },
});
}
private static string? ReadOptionalString(JObject json, string property)
{
var token = json[property];
if (token == null || token.Type == JTokenType.Null) return null;
var value = token.ToString().Trim();
return value.Length == 0 ? null : value;
}
private static async Task<HttpResponseData> BadRequestAsync(HttpRequestData req, string message)
{
var response = req.CreateResponse(HttpStatusCode.BadRequest);
await response.WriteAsJsonAsync(new { success = false, message });
return response;
}
}
}
@@ -128,6 +128,11 @@ namespace AutopilotMonitor.Functions.Functions.Rules
return badRequest;
}
// Same anti-spoof stamp as CreateRule: on a true update the service replaces
// this with the original author (immutable attribution), but a full-payload PUT
// that upserts a rule with no existing row must not store the payload's author.
rule.Author = TenantHelper.GetUserDisplayName(req) ?? "Autopilot Monitor";
try
{
var success = await _ruleService.UpdateRuleAsync(tenantId, rule);
@@ -1,4 +1,5 @@
using System.Net;
using AutopilotMonitor.Functions.Helpers;
using AutopilotMonitor.Functions.Services;
using AutopilotMonitor.Shared.Models;
using Microsoft.Azure.Functions.Worker;
@@ -110,6 +111,11 @@ namespace AutopilotMonitor.Functions.Functions.Rules
if (scopeError != null)
return await BadRequestAsync(req, scopeError);
// Same anti-spoof stamp as the tenant route: true updates keep the original
// author (service-level preservation); a PUT-upsert with no existing row
// stores the editing admin, never the payload's author.
rule.Author = TenantHelper.GetUserDisplayName(req) ?? "Autopilot Monitor";
_logger.LogInformation("Global admin updating gather rule {RuleId} for tenant {TenantId}", ruleId, tenantId);
var success = await _gatherRuleService.UpdateRuleAsync(tenantId, rule);
@@ -0,0 +1,153 @@
using System;
using System.Collections.Generic;
using System.Collections.Specialized;
using System.Globalization;
using System.Linq;
using System.Text;
using AutopilotMonitor.Shared.Models;
using AutopilotMonitor.Shared.Pagination;
namespace AutopilotMonitor.Functions.Pagination
{
/// <summary>
/// Pure helpers for the <c>GET /api/global/session-annotations</c> pagination
/// surface. GA-only at the policy layer; every optional filter is a server-side
/// <em>filter</em>, not an authorization scope, so the continuation token binds
/// the caller's identity + the whole active filter set (a token issued for one
/// filter view can't be replayed against another).
/// </summary>
public static class SessionAnnotationsPagination
{
public const int DefaultPageSize = 200;
public const int MaxPageSize = 1000;
public static string Fingerprint(
string callerTenantId, string? filterTenantId, string? lane, string? verdict,
string? ruleId, DateTime? dateFrom, DateTime? dateTo) =>
ContinuationToken.ComputeFingerprint(new[]
{
new KeyValuePair<string, string?>("scope", "session-annotations"),
new KeyValuePair<string, string?>("tenantId", callerTenantId),
new KeyValuePair<string, string?>("filterTenantId", filterTenantId),
new KeyValuePair<string, string?>("lane", lane),
new KeyValuePair<string, string?>("verdict", verdict),
new KeyValuePair<string, string?>("ruleId", ruleId),
new KeyValuePair<string, string?>("dateFrom", dateFrom?.ToString("o", CultureInfo.InvariantCulture)),
new KeyValuePair<string, string?>("dateTo", dateTo?.ToString("o", CultureInfo.InvariantCulture)),
});
public sealed class Parsed
{
public string? FilterTenantId { get; init; }
public string? Lane { get; init; }
public string? Verdict { get; init; }
public string? RuleId { get; init; }
public DateTime? DateFrom { get; init; }
public DateTime? DateTo { get; init; }
public int PageSize { get; init; } = DefaultPageSize;
public string? Continuation { get; init; }
public string? Error { get; init; }
}
public static Parsed ParseQuery(NameValueCollection? query)
{
var pageSize = DefaultPageSize;
var pageSizeRaw = query?["pageSize"];
if (!string.IsNullOrEmpty(pageSizeRaw))
{
if (!int.TryParse(pageSizeRaw, NumberStyles.Integer, CultureInfo.InvariantCulture, out var n))
return new Parsed { Error = "pageSize must be an integer" };
if (n < 1 || n > MaxPageSize)
return new Parsed { Error = $"pageSize must be between 1 and {MaxPageSize}" };
pageSize = n;
}
var lane = Normalize(query?["lane"]);
if (lane != null && !AnnotationLanes.All.Contains(lane))
return new Parsed { Error = $"lane must be one of: {string.Join(", ", AnnotationLanes.All)}" };
var verdict = Normalize(query?["verdict"]);
if (verdict != null && !AnnotationVerdicts.All.Contains(verdict))
return new Parsed { Error = $"verdict must be one of: {string.Join(", ", AnnotationVerdicts.All)}" };
if (!TryParseDate(query?["dateFrom"], out var dateFrom))
return new Parsed { Error = "dateFrom must be an ISO 8601 date" };
if (!TryParseDate(query?["dateTo"], out var dateTo))
return new Parsed { Error = "dateTo must be an ISO 8601 date" };
return new Parsed
{
FilterTenantId = NullIfEmpty(query?["tenantId"]),
Lane = lane,
Verdict = verdict,
RuleId = NullIfEmpty(query?["ruleId"]),
DateFrom = dateFrom,
DateTo = dateTo,
PageSize = pageSize,
Continuation = NullIfEmpty(query?["continuation"]),
};
}
public static bool TryAcceptContinuation(
Parsed parsed,
string callerTenantId,
out string azureToken,
out string? rejectReason)
{
var fp = Fingerprint(
callerTenantId, parsed.FilterTenantId, parsed.Lane, parsed.Verdict,
parsed.RuleId, parsed.DateFrom, parsed.DateTo);
return ContinuationToken.TryDecode(parsed.Continuation!, callerTenantId, fp, out azureToken, out rejectReason);
}
public static string EncodeContinuation(Parsed parsed, string callerTenantId, string rawAzureToken)
{
var fp = Fingerprint(
callerTenantId, parsed.FilterTenantId, parsed.Lane, parsed.Verdict,
parsed.RuleId, parsed.DateFrom, parsed.DateTo);
return ContinuationToken.Encode(rawAzureToken, callerTenantId, fp);
}
public const string GlobalBasePath = "/api/global/session-annotations";
// Under /api/sessions so the platform client-cert exclusion prefix covers it.
public const string TenantBasePath = "/api/sessions/annotations/list";
public static string BuildNextLink(Parsed parsed, string wireContinuation, string basePath = GlobalBasePath)
{
var sb = new StringBuilder(basePath);
sb.Append("?pageSize=").Append(parsed.PageSize.ToString(CultureInfo.InvariantCulture));
sb.Append("&continuation=").Append(Uri.EscapeDataString(wireContinuation));
AppendIfSet(sb, "tenantId", parsed.FilterTenantId);
AppendIfSet(sb, "lane", parsed.Lane);
AppendIfSet(sb, "verdict", parsed.Verdict);
AppendIfSet(sb, "ruleId", parsed.RuleId);
AppendIfSet(sb, "dateFrom", parsed.DateFrom?.ToString("o", CultureInfo.InvariantCulture));
AppendIfSet(sb, "dateTo", parsed.DateTo?.ToString("o", CultureInfo.InvariantCulture));
return sb.ToString();
}
private static void AppendIfSet(StringBuilder sb, string name, string? value)
{
if (!string.IsNullOrEmpty(value))
sb.Append('&').Append(name).Append('=').Append(Uri.EscapeDataString(value!));
}
private static bool TryParseDate(string? raw, out DateTime? value)
{
value = null;
if (string.IsNullOrEmpty(raw)) return true;
if (!DateTime.TryParse(
raw, CultureInfo.InvariantCulture,
DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal, out var parsed))
return false;
value = parsed;
return true;
}
private static string? Normalize(string? raw) =>
string.IsNullOrEmpty(raw) ? null : raw.ToLowerInvariant();
private static string? NullIfEmpty(string? raw) =>
string.IsNullOrEmpty(raw) ? null : raw;
}
}
@@ -234,6 +234,14 @@ public static class EndpointAccessPolicyCatalog
// sessions/{sessionId}/signals + /decision-graph live in the GlobalAdminOnly block below
// (Inspector v1 — Plan §M6). Lift back to MemberRead+QueryParam at the v2 adminMode lift.
new("GET", "sessions/{sessionId}/analysis", EndpointPolicy.MemberRead, TenantScoping.QueryParam),
// Member tier: annotation reads are safe for every tenant member because the handler
// filters the platform-internal globaladmin lane for callers without global scope
// (GetSessionAnnotationsFunction.FilterLanesForCaller; the tenant list excludes the
// lane server-side in the OData query). The list route deliberately lives under the
// /api/sessions client-cert exclusion prefix, three segments deep (a two-segment
// literal would be eaten by the sessions/{sessionId} template).
new("GET", "sessions/{sessionId}/annotations", EndpointPolicy.MemberRead, TenantScoping.QueryParam),
new("GET", "sessions/annotations/list", 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 +305,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 +461,8 @@ public static class EndpointAccessPolicyCatalog
new("GET", "global/session-reports", EndpointPolicy.GlobalReadOrAdmin, TenantScoping.QueryParam),
new("GET", "global/session-reports/download-url", EndpointPolicy.GlobalReadOrAdmin),
new("PATCH", "global/session-reports/{reportId}/note", EndpointPolicy.GlobalAdminOnly),
// Flywheel evaluation stream: all annotation lanes across tenants (verdict/rule filters).
new("GET", "global/session-annotations", EndpointPolicy.GlobalReadOrAdmin, TenantScoping.QueryParam),
new("GET", "global/rules/gather", EndpointPolicy.GlobalReadOrAdmin, TenantScoping.QueryParam),
new("GET", "global/rules/analyze", EndpointPolicy.GlobalReadOrAdmin, TenantScoping.QueryParam),
// Cross-tenant rule WRITES (edit/toggle/delete an existing rule in a foreign tenant). GlobalAdminOnly
@@ -12,6 +12,7 @@ using Azure.Data.Tables;
using AutopilotMonitor.Functions.Security;
using AutopilotMonitor.Functions.Services.Tables;
using AutopilotMonitor.Shared;
using AutopilotMonitor.Shared.Models;
using AutopilotMonitor.Shared.Models.Deletion;
using Microsoft.Extensions.Logging;
@@ -117,17 +118,25 @@ namespace AutopilotMonitor.Functions.Services.Deletion
// ---- Step 16: F1 time-attribution breakdown (PK=tenant, RK=sessionId; PR2). ----
await AddPkRkExactStepAsync(manifest, order: 16, table: Constants.TableNames.SessionTimeBreakdowns, partitionKey: tenantId, rowKey: sessionId, cancellationToken);
// ---- Steps 17 + 18: SoftwareInventory side-row (omit both for pre-side-row sessions). ----
// ---- Steps 17-19: session annotations (PK=tenant, RK="{sessionId}_{lane}"). ----
// Lanes are a closed set of 3, so exact-RK steps cover every possible row.
var annotationOrder = 17;
foreach (var lane in AnnotationLanes.All)
{
await AddPkRkExactStepAsync(manifest, order: annotationOrder++, table: Constants.TableNames.SessionAnnotations, partitionKey: tenantId, rowKey: $"{sessionId}_{lane}", cancellationToken);
}
// ---- Steps 20 + 21: SoftwareInventory side-row (omit both for pre-side-row sessions). ----
var contributionsRow = await _reader.GetEntityOrNullAsync(
Constants.TableNames.SessionInventoryContributions, tenantId, sessionId, cancellationToken);
if (contributionsRow != null)
{
AddSoftwareInventoryDecrementStep(manifest, order: 17, contributionsRow);
AddContributionsRowStep(manifest, order: 18, contributionsRow);
AddSoftwareInventoryDecrementStep(manifest, order: 20, contributionsRow);
AddContributionsRowStep(manifest, order: 21, contributionsRow);
}
// ---- Step 19: Tombstone (SessionsIndex first, then Sessions). ----
AddTombstoneStep(manifest, order: 19, sessionsIndexRow, sessionRow);
// ---- Step 22: Tombstone (SessionsIndex first, then Sessions). ----
AddTombstoneStep(manifest, order: 22, sessionsIndexRow, sessionRow);
// PreflightCounts derive from each step's RowCount, plus the AGGREGATE decrements length.
manifest.PreflightCounts = ComputePreflightCounts(manifest);
@@ -433,7 +442,9 @@ namespace AutopilotMonitor.Functions.Services.Deletion
continue;
}
if (string.IsNullOrEmpty(step.Table)) continue;
counts[CamelCase(step.Table!)] = step.RowCount;
// Sum, don't overwrite: SessionAnnotations emits one step per lane on the same table.
var key = CamelCase(step.Table!);
counts[key] = counts.TryGetValue(key, out var existing) ? existing + step.RowCount : step.RowCount;
}
return counts;
}
@@ -89,6 +89,10 @@ namespace AutopilotMonitor.Functions.Services.Offboarding
// save), so the wipe cannot re-create a snapshot afterwards. The admin-config
// "GlobalConfig" partition never matches a tenant GUID and survives untouched.
Constants.TableNames.ConfigurationBackups,
// Session annotations (PK=tenantId). Deliberately WIPED — unlike Feedback, these
// are customer-entered notes tied to the tenant's sessions, not product feedback.
// Rows also die per-session via the deletion manifest; this covers offboarding.
Constants.TableNames.SessionAnnotations,
};
// Plan §6.4 — composite-PK "{tenantId}_..." wipes (Variant A range).
@@ -29,6 +29,7 @@ namespace AutopilotMonitor.Functions.Services
private readonly SessionReportDiagnosticsArchiveCopier _diagnosticsArchiveCopier;
private readonly TenantConfigurationService _configService;
private readonly IRuleRepository _ruleRepo;
private readonly ISessionAnnotationRepository _annotationRepo;
private const string ContainerName = "session-reports";
public SessionReportService(
@@ -38,6 +39,7 @@ namespace AutopilotMonitor.Functions.Services
SessionReportDiagnosticsArchiveCopier diagnosticsArchiveCopier,
TenantConfigurationService configService,
IRuleRepository ruleRepo,
ISessionAnnotationRepository annotationRepo,
ILogger<SessionReportService> logger)
{
_notificationRepo = notificationRepo;
@@ -47,6 +49,7 @@ namespace AutopilotMonitor.Functions.Services
_diagnosticsArchiveCopier = diagnosticsArchiveCopier;
_configService = configService;
_ruleRepo = ruleRepo;
_annotationRepo = annotationRepo;
}
/// <summary>
@@ -95,6 +98,13 @@ namespace AutopilotMonitor.Functions.Services
// have changed by the time an operator investigates). Fail-soft; never blocks.
var tenantContext = await CollectTenantContextAsync(request.TenantId, reportId);
// Human annotations at report time — the submitter's own verdict/notes are exactly
// the context an investigating operator wants first, and the snapshot survives the
// session's retention/delete. ALL lanes are included: the report blob is readable
// only through the GA-only session-reports routes, so the platform-internal
// globaladmin lane never reaches tenant callers this way. Fail-soft; never blocks.
var annotations = await CollectAnnotationsAsync(request.TenantId, request.SessionId, reportId);
// Decode attachments up front so report-metadata.json can record their outcome
// instead of silently dropping invalid payloads.
var (screenshotBytes, screenshotStatus) = DecodeAttachment(request.ScreenshotBase64, reportId, "screenshot");
@@ -128,6 +138,12 @@ namespace AutopilotMonitor.Functions.Services
AddTextEntry(archive, "timeline.txt", request.TimelineExportTxt);
}
// Human annotations snapshot (all lanes) — omitted when none exist.
if (annotations is { Count: > 0 })
{
AddJsonEntry(archive, "annotations.json", annotations);
}
// Report metadata
AddJsonEntry(archive, "report-metadata.json", new
{
@@ -157,6 +173,9 @@ namespace AutopilotMonitor.Functions.Services
screenshot = screenshotStatus,
agentLog = agentLogStatus
},
// Count only — the rows themselves live in annotations.json. 0 with a
// missing annotations.json ⇒ either none existed or the read failed soft.
annotationCount = annotations?.Count ?? 0,
tenantContext
});
@@ -360,6 +379,35 @@ namespace AutopilotMonitor.Functions.Services
}
}
/// <summary>
/// Snapshot of the session's human annotations (all lanes) for annotations.json.
/// Fail-soft: a failed read yields null and never blocks the report.
/// </summary>
private async Task<List<object>?> CollectAnnotationsAsync(string tenantId, string sessionId, string reportId)
{
try
{
var rows = await _annotationRepo.GetForSessionAsync(tenantId, sessionId);
return rows.Select(a => (object)new
{
lane = a.Lane,
verdict = a.Verdict,
note = a.Note,
authorUpn = a.AuthorUpn,
authorDisplayName = a.AuthorDisplayName,
createdByUpn = a.CreatedByUpn,
createdAtUtc = a.CreatedAtUtc,
updatedAtUtc = a.UpdatedAtUtc,
ruleIds = a.RuleIds,
}).ToList();
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Report {ReportId}: annotations snapshot failed — omitted from package", reportId);
return null;
}
}
/// <summary>
/// Decodes an optional base64 attachment. Returns the bytes plus a status string
/// ("none" | "included" | "invalid-base64") that is persisted in report-metadata.json —
+4 -4
View File
@@ -24,7 +24,7 @@
"@types/compression": "^1.8.1",
"@types/express": "^5.0.6",
"@types/node": "^22.20.1",
"tsx": "^4.23.1",
"tsx": "^4.23.11",
"typescript": "^5.0.0",
"vitest": "^4.1.10"
},
@@ -3823,9 +3823,9 @@
"optional": true
},
"node_modules/tsx": {
"version": "4.23.1",
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.1.tgz",
"integrity": "sha512-GQHnkIfxyx1wYCOS/wonik5MVRZU9hi1TEZmzGZSCJB1y9YgoZ8H6itNE/u4suE+yLmOzuE4E5S4TZ/ZX2wcWQ==",
"version": "4.23.11",
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.11.tgz",
"integrity": "sha512-Ry2oTEUnhBdeEdWIztY8kf3/nBGnPnjMLVGL0YfdRXMORuPER5NlKmayqxtxRxwB1xBN+RivRaJfe7PM1rtiyw==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -32,7 +32,7 @@
"@types/compression": "^1.8.1",
"@types/express": "^5.0.6",
"@types/node": "^22.20.1",
"tsx": "^4.23.1",
"tsx": "^4.23.11",
"typescript": "^5.0.0",
"vitest": "^4.1.10"
},
@@ -113,6 +113,65 @@ describe('gather guardrails (agent matching semantics)', () => {
expect(errors(validateRuleDraft(bad).findings).some((m) => m.includes('hard-blocked'))).toBe(true);
});
it('rejects the registry-hive store path (System32\\config) as hard-blocked', () => {
const bad = validGather();
bad.collectorType = 'file';
bad.target = 'C:\\Windows\\System32\\config\\SAM';
expect(errors(validateRuleDraft(bad).findings).some((m) => m.includes('hard-blocked'))).toBe(true);
});
it('validates json/xml targets with file-path semantics', () => {
const okJson = validGather();
okJson.collectorType = 'json';
okJson.target = 'C:\\ProgramData\\Microsoft\\IntuneManagementExtension\\Logs\\state.json';
expect(validateRuleDraft(okJson).valid).toBe(true);
const blockedJson = validGather();
blockedJson.collectorType = 'json';
blockedJson.target = 'C:\\Users\\admin\\AppData\\Local\\app\\state.json';
expect(errors(validateRuleDraft(blockedJson).findings).some((m) => m.includes('hard-blocked'))).toBe(true);
const badXml = validGather();
badXml.collectorType = 'xml';
badXml.target = 'D:\\NotAllowed\\config.xml';
expect(errors(validateRuleDraft(badXml).findings).some((m) => m.includes('allowed file prefix'))).toBe(true);
});
it('rejects hard-blocked command patterns and over-length commands', () => {
const blocked = validGather();
blocked.collectorType = 'command_allowlisted';
blocked.target = 'Get-Tpm; Invoke-WebRequest -Uri https://exfil.example';
expect(errors(validateRuleDraft(blocked).findings).some((m) => m.includes('hard-blocked pattern'))).toBe(true);
const long = validGather();
long.collectorType = 'command_allowlisted';
long.target = 'Get-Something ' + 'a'.repeat(2100);
expect(errors(validateRuleDraft(long).findings).some((m) => m.includes('character hard limit'))).toBe(true);
});
it('still accepts allowlisted commands that contain no blocked pattern', () => {
const ok = validGather();
ok.collectorType = 'command_allowlisted';
// must not be caught by the "certutil -urlcache" pattern
ok.target = 'certutil -store My';
expect(validateRuleDraft(ok).valid).toBe(true);
});
it('rejects activePhases + activeFromPhase set together (backend 400 parity)', () => {
const bad = validGather();
bad.activePhases = ['DeviceSetup'];
bad.activeFromPhase = 'AccountSetup';
expect(errors(validateRuleDraft(bad).findings).some((m) => m.includes('mutually exclusive'))).toBe(true);
const okPhases = validGather();
okPhases.activePhases = ['DeviceSetup'];
expect(validateRuleDraft(okPhases).valid).toBe(true);
const okFrom = validGather();
okFrom.activeFromPhase = 'AccountSetup';
expect(validateRuleDraft(okFrom).valid).toBe(true);
});
it('accepts an allow-listed file target and rejects others', () => {
const ok = validGather();
ok.collectorType = 'file';
@@ -108,6 +108,7 @@ describe('role catalog snapshot — privilege-leak guard', () => {
// This is the source of truth. A new tool MUST be added here, or the GA test
// fails — that failure is the deliberate prompt to decide its role placement.
const GA_FULL = [
'annotate_session',
'get_api_usage',
'get_app_install_metrics',
'get_audit_logs',
@@ -136,6 +137,7 @@ describe('role catalog snapshot — privilege-leak guard', () => {
'get_usage_metrics',
'get_vulnerability_summary',
'list_blocked_devices',
'list_session_annotations',
'list_session_reports',
'list_tables',
'list_tenant_config_backups',
@@ -178,17 +180,23 @@ describe('role catalog snapshot — privilege-leak guard', () => {
'update_tenant_config',
];
// Session-annotation write surface: strictGa only — a read-only Global Reader
// reads annotations (list_session_annotations) but never writes the GA lane.
const ANNOTATION_WRITE_GA_STRICT = ['annotate_session'];
// Platform-only tools: a non-platform caller (tenant or delegated) gets no
// cross-fleet aggregate surface at all. Superset of RAW_GA_STRICT and
// CONFIG_WRITE_GA_STRICT (those are also platform-only) plus the curated
// cross-tenant aggregates.
const PLATFORM_ONLY = [
'annotate_session',
'get_api_usage',
'get_ops_events',
'get_platform_metrics',
'get_tenant_config',
'get_tenant_config_schema',
'list_blocked_devices',
'list_session_annotations',
'list_session_reports',
'list_tables',
'list_tenant_config_backups',
@@ -212,9 +220,9 @@ describe('role catalog snapshot — privilege-leak guard', () => {
expect(registeredToolNames(true, true)).toEqual(GA_FULL);
});
it('Global Reader = GA minus the secret-bearing raw tools and the config-write surface (strictGa split)', () => {
it('Global Reader = GA minus the secret-bearing raw tools, the config-write surface and the annotation write (strictGa split)', () => {
expect(registeredToolNames(true, false)).toEqual(
without(GA_FULL, [...RAW_GA_STRICT, ...CONFIG_WRITE_GA_STRICT]));
without(GA_FULL, [...RAW_GA_STRICT, ...CONFIG_WRITE_GA_STRICT, ...ANNOTATION_WRITE_GA_STRICT]));
});
it('tenant user = GA minus all platform-only tools', () => {
@@ -232,6 +240,7 @@ describe('role catalog snapshot — privilege-leak guard', () => {
for (const [label, list] of [
['RAW_GA_STRICT', RAW_GA_STRICT],
['CONFIG_WRITE_GA_STRICT', CONFIG_WRITE_GA_STRICT],
['ANNOTATION_WRITE_GA_STRICT', ANNOTATION_WRITE_GA_STRICT],
['PLATFORM_ONLY', PLATFORM_ONLY],
['DELEGATED_HIDDEN', DELEGATED_HIDDEN],
['DELEGATED_ADDED', DELEGATED_ADDED],
@@ -252,4 +261,9 @@ describe('role catalog snapshot — privilege-leak guard', () => {
const leaked = CONFIG_WRITE_GA_STRICT.filter((n) => !PLATFORM_ONLY.includes(n));
expect(leaked, 'a config-write GA-strict tool is not also marked platform-only').toEqual([]);
});
it('the annotation write tool is a subset of the platform-only tools', () => {
const leaked = ANNOTATION_WRITE_GA_STRICT.filter((n) => !PLATFORM_ONLY.includes(n));
expect(leaked, 'the annotation write tool is not also marked platform-only').toEqual([]);
});
});
@@ -277,6 +277,7 @@ export const DEVICE_PROPERTIES_CATALOG = {
'autopilot_profile_missing.profileAvailable': { type: 'string', description: 'Always "0" — Windows recorded that no Autopilot profile was available during OOBE' },
'autopilot_profile_missing.zeroTouchTenantDomain': { type: 'string', description: 'CloudAssignedTenantDomain from the cached ZeroTouchConfig (empty when no profile was downloaded)' },
'autopilot_profile_missing.likelyCauses': { type: 'string', description: 'Comma-separated candidate causes (profile_not_assigned, profile_assignment_not_propagated) — the device itself passed backend device-registration validation, so the gap is the profile assignment, not the registration' },
'autopilot_profile_missing.isCloudPc': { type: 'boolean', description: 'true when the device is a Windows 365 Cloud PC — there the missing profile is expected (Cloud PCs are provisioned by the Windows 365 service, not Autopilot) and the event is emitted as Info instead of Warning' },
},
hardware_spec: {
'hardware_spec.cpuName': { type: 'string', description: 'CPU model name (e.g. "Intel Core i7-1265U")' },
@@ -70,6 +70,14 @@ export const RULE_AUTHORING_GUIDE = {
file:
'Check file/directory existence, optionally read content. target = path under an allow-listed ' +
'prefix. parameters: readContent ("true"). C:\\Users is blocked.',
json:
'Query a JSON file with a JSONPath expression. target = file path under an allow-listed ' +
'prefix (same file guardrails as collectorType "file", 200 KB cap). parameters: jsonpath ' +
'(REQUIRED, e.g. "$.status.lastSync"), maxResults (default 20, max 100).',
xml:
'Query an XML file with an XPath expression. target = file path under an allow-listed ' +
'prefix (same file guardrails as collectorType "file", 200 KB cap). parameters: xpath ' +
'(REQUIRED), namespaces (optional "prefix=uri;prefix2=uri2"), maxResults (default 20, max 100).',
command_allowlisted:
'Run one of the exactly allow-listed commands (see rule_guardrails allowedCommands). ' +
'Matching is EXACT (trimmed, case-insensitive) — no arguments can be added or removed.',
@@ -247,7 +255,7 @@ export const RULE_AUTHORING_GUIDE = {
analyzeCategories: ['network', 'identity', 'enrollment', 'apps', 'esp', 'device', 'security'],
analyzeSeverities: ['info', 'warning', 'high', 'critical'],
gatherOutputSeverities: ['Info', 'Warning', 'Error', 'Critical'],
gatherCollectorTypes: ['registry', 'eventlog', 'wmi', 'file', 'command_allowlisted', 'logparser'],
gatherCollectorTypes: ['registry', 'eventlog', 'wmi', 'file', 'json', 'xml', 'command_allowlisted', 'logparser'],
gatherTriggers: ['startup', 'phase_change', 'phase_exit', 'interval', 'on_event'],
analyzeConditionSources: ['event_type', 'event_data', 'event_data_array', 'phase_duration', 'event_count', 'app_install_duration', 'event_correlation'],
},
@@ -98,6 +98,8 @@ export const GATHER_RULE_SCHEMA: Record<string, unknown> = {
"eventlog",
"wmi",
"file",
"json",
"xml",
"command_allowlisted",
"logparser"
]
@@ -1000,6 +1002,27 @@ export const RULE_GUARDRAILS = {
"C:\\Install\\Log"
],
"blockedFilePrefixes": [
"C:\\Users"
]
"C:\\Users",
"C:\\Windows\\System32\\config"
],
"blockedCommandPatterns": [
"Invoke-WebRequest",
"Invoke-RestMethod",
"Start-BitsTransfer",
"wget",
"curl",
"certutil -urlcache",
"New-LocalUser",
"Add-LocalGroupMember",
"net user",
"net localgroup",
"bcdedit",
"bcdboot",
"schtasks /create",
"Register-ScheduledTask",
"Remove-Item -Recurse",
"Format-Volume",
"Set-ExecutionPolicy"
],
"maxCommandLength": 2000
} as const;
@@ -86,10 +86,13 @@ for (const entry of RULE_GUARDRAILS.wmiQueryPrefixes) {
else residualWmiPrefixes.push(entry);
}
/** Hard blocks enforced in agent CODE (not liftable via guardrails.json). Keep in
* sync with GatherRuleGuards.cs — additions there are fail-safe (agent still blocks),
/** Hard blocks enforced in agent CODE (not liftable via guardrails.json — the JSON
* mirrors them for display and pre-flight; an agent-side parity test pins the two
* equal). Additions in GatherRuleGuards.cs are fail-safe: the agent still blocks,
* missing ones here just cost a pre-flight warning. */
const HARD_BLOCKED_PATH_PREFIXES = ['C:\\Users', 'C:\\Windows\\System32\\config'];
const HARD_BLOCKED_PATH_PREFIXES: readonly string[] = RULE_GUARDRAILS.blockedFilePrefixes;
const HARD_BLOCKED_COMMAND_PATTERNS: readonly string[] = RULE_GUARDRAILS.blockedCommandPatterns;
const MAX_COMMAND_LENGTH: number = RULE_GUARDRAILS.maxCommandLength;
/** Segment-bounded prefix match: target equals the prefix or continues with '\'. */
function pathPrefixAllowed(target: string, prefixes: readonly string[]): boolean {
@@ -121,6 +124,8 @@ function checkGatherTarget(collectorType: string, target: string): ValidationFin
break;
}
case 'file':
case 'json':
case 'xml':
case 'logparser': {
const blocked = HARD_BLOCKED_PATH_PREFIXES.find((p) => pathPrefixAllowed(target, [p]));
if (blocked) {
@@ -162,6 +167,24 @@ function checkGatherTarget(collectorType: string, target: string): ValidationFin
break;
}
case 'command_allowlisted': {
// Hard guards enforced in agent CODE even in unrestricted mode (mirrors
// GatherRuleGuards.IsCommandAllowed order: length cap → blocked patterns → allowlist).
if (target.length > MAX_COMMAND_LENGTH) {
findings.push({
level: 'error',
message: `guardrails: command exceeds the ${MAX_COMMAND_LENGTH}-character hard limit — the agent always refuses this, even in unrestricted mode.`,
});
break;
}
const targetLower = target.toLowerCase();
const blockedPattern = HARD_BLOCKED_COMMAND_PATTERNS.find((p) => targetLower.includes(p.toLowerCase()));
if (blockedPattern) {
findings.push({
level: 'error',
message: `guardrails: command contains hard-blocked pattern "${blockedPattern}" — the agent always refuses this, even in unrestricted mode. It cannot be allow-listed.`,
});
break;
}
const t = target.trim().toLowerCase();
if (!flatCommands.some((c) => c.trim().toLowerCase() === t)) {
findings.push({
@@ -363,6 +386,18 @@ function lintGatherRule(rule: Record<string, unknown>): ValidationFinding[] {
const findings: ValidationFinding[] = [];
const trigger = rule.trigger as string | undefined;
// Mirrors GatherRulesFunction.ValidateScopeAndEmitMode: both scope fields set
// is a 400 at create/update time (the schema enums cover the token values, but
// cannot express this exclusivity).
const hasActivePhases = Array.isArray(rule.activePhases) && rule.activePhases.length > 0;
const hasFromPhase = typeof rule.activeFromPhase === 'string' && rule.activeFromPhase.length > 0;
if (hasActivePhases && hasFromPhase) {
findings.push({
level: 'error',
message: 'activePhases and activeFromPhase are mutually exclusive — set only one (the backend rejects rules with both, HTTP 400).',
});
}
if (trigger === 'interval' && typeof rule.intervalSeconds !== 'number') {
findings.push({ level: 'error', message: 'trigger "interval" requires intervalSeconds.' });
}
@@ -785,6 +785,91 @@ export function registerAdminTools(server: McpServer, ga: boolean, strictGa: boo
})
);
// Tool 18b: list_session_annotations — platform scope (GA + Global Reader).
if (ga) server.registerTool(
'list_session_annotations',
{
title: 'List Session Annotations',
description:
'List human session annotations across tenants — the rule-quality evaluation stream. Each annotation is a ' +
'per-session, per-lane row (lane = operator | tenantadmin | globaladmin) carrying a structured verdict ' +
'(root_cause_confirmed | analysis_wrong | different_problem | inconclusive), an optional free-text note, the ' +
'author, and a snapshot of the rule ids that had fired for the session at write time (ruleIds). ' +
'Evaluate rule quality by filtering: verdict + ruleId → confirmed vs false-positive rate per rule; ' +
'lane=globaladmin → the platform team\'s own labels. Platform scope only. ' +
'Pagination: when "nextLink" is present, pass the whole string back as "continuation"; stop when absent.',
inputSchema: {
tenantId: TenantGuidSchema.optional().describe('Optional — filter to a single tenant. Omit for cross-tenant view.'),
lane: z.enum(['operator', 'tenantadmin', 'globaladmin']).optional()
.describe('Optional — filter to one annotation lane.'),
verdict: z.enum(['root_cause_confirmed', 'analysis_wrong', 'different_problem', 'inconclusive']).optional()
.describe('Optional — filter to one structured verdict.'),
ruleId: z.string().optional()
.describe('Optional — only annotations whose rule-id snapshot contains this rule (e.g. "ANALYZE-ESP-001").'),
dateFrom: z.string().optional().describe('ISO 8601 UTC timestamp — inclusive lower bound on the last-updated time.'),
dateTo: z.string().optional().describe('ISO 8601 UTC timestamp — exclusive upper bound on the last-updated time.'),
pageSize: z.coerce.number().int().min(1).max(1000).optional().default(200)
.describe('Page size (1-1000, default 200). Returns this many annotations per call; follow nextLink to fetch more.'),
continuation: z.string().optional()
.describe('Either the opaque "continuation" value from a prior response or the full nextLink path — both are accepted; the latter is preferred so all filters round-trip correctly.'),
},
annotations: READ_ONLY,
},
async (args) => withToolTelemetry('list_session_annotations', args, async () => {
try {
const { tenantId, lane, verdict, ruleId, dateFrom, dateTo, pageSize, continuation } = args;
const path = followNextLink(
'/api/global/session-annotations',
{ tenantId, lane, verdict, ruleId, dateFrom, dateTo, pageSize },
continuation,
);
const data = await apiFetch(path);
return toolResultText(data, MAX_RESULT_SIZE_CHARS.adminStream);
} catch (error: unknown) {
return toolError('list_session_annotations', args, error);
}
})
);
// Tool 18c: annotate_session — real Global Admin only (write).
if (strictGa) server.registerTool(
'annotate_session',
{
title: 'Annotate Session',
description:
'Write the platform team\'s annotation on an enrollment session — a structured verdict about the analysis ' +
'plus an optional note. Global Admin only; always writes the platform-internal "globaladmin" lane (never a ' +
'tenant\'s own lanes), which tenant users cannot see. Use after debugging a session to label it: verdict ' +
'root_cause_confirmed | analysis_wrong | different_problem | inconclusive. The backend stamps the author from ' +
'your identity and snapshots the currently-fired rule ids onto the annotation for later rule-quality ' +
'evaluation (list_session_annotations). Passing BOTH verdict and note as null clears the annotation. ' +
'Re-annotating overwrites the previous verdict/note (first author + creation time are preserved).',
inputSchema: {
sessionId: SessionIdSchema.describe('Session UUID to annotate.'),
tenantId: TenantGuidSchema.optional()
.describe('Optional — the session\'s tenant. If omitted, auto-resolved from the session.'),
verdict: z.enum(['root_cause_confirmed', 'analysis_wrong', 'different_problem', 'inconclusive']).nullable().optional()
.describe('Structured verdict, or null. At least one of verdict/note must be non-null unless clearing.'),
note: z.string().max(4096).nullable().optional()
.describe('Free-text note (max 4096 chars), or null.'),
},
annotations: MUTATING,
},
async (args) => withToolTelemetry('annotate_session', args, async () => {
try {
const { sessionId, tenantId, verdict, note } = args;
const query = tenantId ? `?tenantId=${encodeURIComponent(tenantId)}` : '';
const data = await apiFetch(`/api/sessions/${sessionId}/annotations/globaladmin${query}`, {
method: 'PUT',
body: JSON.stringify({ verdict: verdict ?? null, note: note ?? null }),
});
return toolResultText(data, MAX_RESULT_SIZE_CHARS.small);
} catch (error: unknown) {
return toolError('annotate_session', args, error);
}
})
);
// ── Raw Data Tools ────────────────────────────────────────────────────
// Tool 18: query_raw_events
@@ -468,10 +468,13 @@ export function registerSessionTools(server: McpServer, ga: boolean, delegated:
const q = buildQuery({ tenantId } as Record<string, string | undefined>);
const fetchOpts = { signal: AbortSignal.timeout(90_000) };
const [sessionData, eventsData, analysisData] = await Promise.all([
const [sessionData, eventsData, analysisData, annotationsData] = await Promise.all([
apiFetch(`/api/sessions/${sessionId}${q}`, fetchOpts) as Promise<Record<string, unknown>>,
apiFetch(`/api/sessions/${sessionId}/events${q}`, fetchOpts) as Promise<{ events?: Array<Record<string, unknown>>; count?: number }>,
apiFetch(`/api/sessions/${sessionId}/analysis${q}`, fetchOpts).catch(() => null) as Promise<Record<string, unknown> | null>,
// Human annotations (verdict + note per lane). Backend filters the platform-internal
// globaladmin lane for non-global callers — pass-through, no re-shaping needed.
apiFetch(`/api/sessions/${sessionId}/annotations${q}`, fetchOpts).catch(() => null) as Promise<{ annotations?: Array<Record<string, unknown>> } | null>,
]);
const s = (sessionData.session ?? sessionData) as Record<string, unknown>;
@@ -611,10 +614,17 @@ export function registerSessionTools(server: McpServer, ga: boolean, delegated:
};
}
// Null when no lanes are annotated (or the read failed) — omitting keeps old output stable.
const annotations =
annotationsData?.annotations && annotationsData.annotations.length > 0
? annotationsData.annotations
: null;
const result = {
overview,
keyEvents: mappedEvents,
analysis,
annotations,
stats: {
totalEvents: allEvents.length,
keyEventsTotal: sortedKey.length,
@@ -1,12 +1,16 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>netstandard2.0</TargetFramework>
<!-- netstandard2.0 for the net48 agent; net10.0 so nullable-enabled backend consumers
compile against the real BCL nullability attributes instead of the internal
netstandard2.0 polyfill (NullableAttributes.cs), which would otherwise collide
with System.Runtime's public copy through InternalsVisibleTo (CS0433). -->
<TargetFrameworks>netstandard2.0;net10.0</TargetFrameworks>
<LangVersion>9.0</LangVersion>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<ItemGroup Condition="'$(TargetFramework)' == 'netstandard2.0'">
<PackageReference Include="System.Text.Json" Version="9.0.1" />
</ItemGroup>
@@ -1106,6 +1106,16 @@ namespace AutopilotMonitor.Shared
// for the watchdog (5min staleness threshold for Running, 60min for Queued).
public const string BackupJobs = "BackupJobs";
// Human-entered per-session verdict + note, role-separated into lanes
// (operator / tenantadmin / globaladmin — the globaladmin lane is
// platform-internal and filtered out for tenant callers).
// - PartitionKey = tenantId
// - RowKey = "{sessionId}_{lane}"
// One row per session + lane, edited in place. Wiped on tenant offboarding,
// cascade-deleted with the session, and in the critical-backup set because
// annotations are hand-labeled rule-quality data.
public const string SessionAnnotations = "SessionAnnotations";
/// <summary>
/// Returns all table names for initialization
/// </summary>
@@ -1174,6 +1184,7 @@ namespace AutopilotMonitor.Shared
ScriptNameCache,
BackupJobs,
ConfigurationBackups,
SessionAnnotations,
};
}
@@ -1366,6 +1377,7 @@ namespace AutopilotMonitor.Shared
TableNames.PreviewConfig,
TableNames.PreviewWhitelist,
TableNames.RuleStates,
TableNames.SessionAnnotations,
TableNames.TenantAdmins,
TableNames.TenantConfiguration,
TableNames.TenantOffboardingCustomsArchive,
@@ -0,0 +1,53 @@
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using AutopilotMonitor.Shared.Models;
namespace AutopilotMonitor.Shared.DataAccess
{
/// <summary>
/// Storage for <see cref="SessionAnnotation"/> rows. Backed by the dedicated
/// <c>SessionAnnotations</c> table (PK = tenantId, RK = <c>{sessionId}_{lane}</c>).
/// Rows are wiped on tenant offboarding and cascade-deleted with their session;
/// the table is in the critical-backup set because annotations are hand-labeled data.
/// </summary>
public interface ISessionAnnotationRepository
{
/// <summary>Returns the annotation for one session + lane, or null.</summary>
Task<SessionAnnotation?> GetAsync(string tenantId, string sessionId, string lane);
/// <summary>Returns all lanes present for one session (0–3 rows).</summary>
Task<List<SessionAnnotation>> GetForSessionAsync(string tenantId, string sessionId);
/// <summary>Creates or replaces the row for the annotation's session + lane.</summary>
Task UpsertAsync(SessionAnnotation annotation);
/// <summary>Deletes the row for one session + lane. Missing row is a no-op.</summary>
Task DeleteAsync(string tenantId, string sessionId, string lane);
/// <summary>
/// Global evaluation query (cross-tenant filtered table scan; annotation volume is
/// human-entered and small). All filters optional. <paramref name="ruleId"/> is
/// matched client-side against <see cref="SessionAnnotation.RuleIds"/>, so this
/// method back-fills short pages by looping the Azure continuation until
/// <paramref name="pageSize"/> matches accumulate or the scan is exhausted
/// (bounded round-trips) — a filtered-out row never consumes page budget.
/// </summary>
/// <returns>The page items plus the raw Azure continuation token for the next page (null when done).</returns>
/// <param name="excludeGlobalAdminLane">
/// True for callers without global scope: adds a server-side <c>Lane ne 'globaladmin'</c>
/// clause so the platform-internal lane never consumes page budget (and an explicit
/// <paramref name="lane"/>=globaladmin filter simply yields an empty page).
/// </param>
Task<(List<SessionAnnotation> Items, string? NextRawToken)> QueryPageAsync(
string? tenantId,
string? lane,
string? verdict,
string? ruleId,
DateTime? dateFrom,
DateTime? dateTo,
int pageSize,
string? continuation,
bool excludeGlobalAdminLane = false);
}
}
@@ -1,4 +1,5 @@
using System;
using System.Diagnostics.CodeAnalysis;
using System.Globalization;
using System.Text.RegularExpressions;
@@ -10,10 +11,10 @@ namespace AutopilotMonitor.Shared.Logging
public class CmTraceLogEntry
{
public DateTime Timestamp { get; set; }
public string Message { get; set; }
public string Component { get; set; }
public string Message { get; set; } = string.Empty;
public string Component { get; set; } = string.Empty;
public int Type { get; set; } // 1=Info, 2=Warning, 3=Error
public string Thread { get; set; }
public string Thread { get; set; } = string.Empty;
}
/// <summary>
@@ -42,11 +43,12 @@ namespace AutopilotMonitor.Shared.Logging
/// Attempts to parse a single line of CMTrace-formatted log.
/// Returns true if parsing succeeded, false if the line doesn't match the format.
/// </summary>
public static bool TryParseLine(string line, out CmTraceLogEntry entry)
public static bool TryParseLine(string? line, [NotNullWhen(true)] out CmTraceLogEntry? entry)
{
entry = null;
if (string.IsNullOrEmpty(line) || !line.StartsWith("<![LOG["))
// netstandard2.0's IsNullOrEmpty lacks [NotNullWhen(false)] — assert non-null after it.
if (string.IsNullOrEmpty(line) || !line!.StartsWith("<![LOG["))
return false;
var match = CmTraceRegex.Match(line);
@@ -49,7 +49,7 @@ namespace AutopilotMonitor.Shared.Models
/// a security matter, or a change that needs an administrator's attention. Editable by
/// the tenant's own admins under Settings → Tenant → Contact.
/// <para>
/// Seeded once at onboarding from the Private Preview notification address if one was
/// Seeded once at onboarding from the tenant's notification address if one was
/// given, and never re-synced afterwards: from that point the value belongs to the
/// tenant, and a later edit must not be overwritten by the onboarding source.
/// </para>
@@ -0,0 +1,72 @@
using System;
using System.Collections.Generic;
namespace AutopilotMonitor.Shared.Models
{
/// <summary>
/// Human-entered per-session verdict + free-text note, role-separated into lanes
/// (one row per session + lane, editable in place). Stored in the
/// <c>SessionAnnotations</c> table: PK = tenantId, RK = <c>{sessionId}_{lane}</c>.
/// The <c>globaladmin</c> lane is platform-internal and never returned to tenant
/// callers. Author fields are stamped server-side from the JWT, never from the body.
/// </summary>
public class SessionAnnotation
{
/// <summary>Maximum length of <see cref="Note"/> (matches the feedback comment cap).</summary>
public const int MaxNoteLength = 4096;
public string TenantId { get; set; } = string.Empty;
public string SessionId { get; set; } = string.Empty;
/// <summary>One of <see cref="AnnotationLanes.All"/>.</summary>
public string Lane { get; set; } = string.Empty;
/// <summary>One of <see cref="AnnotationVerdicts.All"/>, or null (note-only annotation).</summary>
public string? Verdict { get; set; }
/// <summary>Free text, ≤ <see cref="MaxNoteLength"/>, or null (verdict-only annotation).</summary>
public string? Note { get; set; }
/// <summary>UPN of the last editor. Server-stamped.</summary>
public string AuthorUpn { get; set; } = string.Empty;
/// <summary>Display name of the last editor. Server-stamped.</summary>
public string AuthorDisplayName { get; set; } = string.Empty;
/// <summary>UPN of the first writer. Immutable across edits.</summary>
public string CreatedByUpn { get; set; } = string.Empty;
public DateTime CreatedAtUtc { get; set; }
public DateTime UpdatedAtUtc { get; set; }
/// <summary>
/// Snapshot of the rule ids that had fired for the session at write time
/// (denormalized so rule-quality evaluation needs no join against RuleResults).
/// </summary>
public List<string> RuleIds { get; set; } = new();
}
/// <summary>Lane discriminators — the RowKey suffix and the write-permission axis.</summary>
public static class AnnotationLanes
{
public const string Operator = "operator";
public const string TenantAdmin = "tenantadmin";
public const string GlobalAdmin = "globaladmin";
public static readonly string[] All = { Operator, TenantAdmin, GlobalAdmin };
}
/// <summary>Structured verdict vocabulary. A verdict is always optional.</summary>
public static class AnnotationVerdicts
{
public const string RootCauseConfirmed = "root_cause_confirmed";
public const string AnalysisWrong = "analysis_wrong";
public const string DifferentProblem = "different_problem";
public const string Inconclusive = "inconclusive";
public static readonly string[] All =
{
RootCauseConfirmed, AnalysisWrong, DifferentProblem, Inconclusive,
};
}
}
@@ -0,0 +1,16 @@
// netstandard2.0 ships none of the nullable-flow attributes, so shared Try-pattern APIs
// could not tell nullable-enabled consumers (backend net10, agent test projects) that an
// out param is non-null on success. Internal polyfill — the consuming compiler matches
// these attributes by full name from metadata, so accessibility does not matter.
#if NETSTANDARD2_0
namespace System.Diagnostics.CodeAnalysis
{
[AttributeUsage(AttributeTargets.Parameter, Inherited = false)]
internal sealed class NotNullWhenAttribute : Attribute
{
public NotNullWhenAttribute(bool returnValue) => ReturnValue = returnValue;
public bool ReturnValue { get; }
}
}
#endif
@@ -1,4 +1,5 @@
using System;
using System.Diagnostics.CodeAnalysis;
namespace AutopilotMonitor.Shared.Services
{
@@ -28,10 +29,9 @@ namespace AutopilotMonitor.Shared.Services
/// normalized base URL (<c>https://{lowercase-host}</c>, no trailing slash) on success.
/// Rules: absolute https URI, default port, no userinfo/path/query/fragment, host
/// matching <see cref="AllowedHostSuffixes"/>. <paramref name="normalized"/> is non-null
/// exactly when this returns <c>true</c> (netstandard2.0 has no usable
/// <c>[NotNullWhen]</c>, so callers must check the bool themselves).
/// exactly when this returns <c>true</c>.
/// </summary>
public static bool TryNormalizeTarget(string candidate, out string? normalized)
public static bool TryNormalizeTarget(string candidate, [NotNullWhen(true)] out string? normalized)
{
normalized = null;
@@ -2,11 +2,14 @@
import { useAuth } from "../../contexts/AuthContext";
import { useRouter } from "next/navigation";
import { useEffect, useState } from "react";
import { useEffect, useState, useSyncExternalStore } from "react";
import { api } from "@/lib/api";
import { DOCS_URL } from "@/utils/config";
import { BrandMark } from "../../components/BrandMark";
// The URL query string is fixed for the lifetime of a page load — nothing to subscribe to.
const subscribeNever = () => () => {};
export default function ActivationPage() {
const { isAuthenticated, isLoading, user, isActivationPending, activationMessage, logout, getAccessToken, refreshUserInfo } = useAuth();
const router = useRouter();
@@ -17,14 +20,17 @@ export default function ActivationPage() {
// ?demo=1 renders the page without auth/redirects (placeholder data) so
// the design can be reviewed locally and on SWA preview environments.
const [demo, setDemo] = useState<boolean | null>(null);
useEffect(() => {
setDemo(new URLSearchParams(window.location.search).has("demo"));
}, []);
// The prerendered HTML uses the server snapshot (false); the client re-renders
// with the real query flag — no hydration mismatch, no effect-phase setState.
const demo = useSyncExternalStore(
subscribeNever,
() => new URLSearchParams(window.location.search).has("demo"),
() => false,
);
// If the tenant is activated (e.g. activated tenant navigates here), redirect away
useEffect(() => {
if (demo === null || demo) return;
if (demo) return;
if (!isLoading && isAuthenticated && user && !isActivationPending) {
if (user.isTenantAdmin || user.isGlobalAdmin) {
router.push("/dashboard");
@@ -41,7 +47,7 @@ export default function ActivationPage() {
// ~1 minute after signup. refreshUserInfo clears isActivationPending on a successful
// auth/me, which triggers the redirect effect above — no manual reload needed.
useEffect(() => {
if (demo === null || demo) return;
if (demo) return;
if (isLoading || !isAuthenticated || !isActivationPending) return;
const id = setInterval(() => {
refreshUserInfo().catch(() => { /* transient — next tick retries */ });
@@ -60,7 +60,10 @@ function BackupDetailContent() {
}, [backupId, getAccessToken, setError]);
useEffect(() => {
void load();
const run = async () => {
await load();
};
void run();
}, [load]);
const openRestoreModal = async (tableName: string, pk: string, rk: string) => {
@@ -230,19 +233,18 @@ function RestoreInputRow({
const [pk, setPk] = useState<string>("");
const [rk, setRk] = useState<string>("");
// Keep the dropdown in sync if the manifest reload changes the restorable set.
useEffect(() => {
if (!restorable.find((t) => t.tableName === tableName)) {
setTableName(restorable[0]?.tableName ?? "");
}
}, [restorable, tableName]);
// The user's pick can go stale when a manifest reload changes the restorable
// set — fall back to the first restorable table instead of syncing state.
const effectiveTableName = restorable.some((t) => t.tableName === tableName)
? tableName
: (restorable[0]?.tableName ?? "");
return (
<div className="flex flex-wrap items-end gap-2">
<label className="flex flex-col gap-1 text-xs text-gray-700 dark:text-gray-300">
Table
<select
value={tableName}
value={effectiveTableName}
onChange={(e) => setTableName(e.target.value)}
className="px-2 py-1 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-800 text-sm font-mono"
>
@@ -271,11 +273,11 @@ function RestoreInputRow({
</label>
<button
type="button"
onClick={() => onSubmit(tableName, pk, rk)}
onClick={() => onSubmit(effectiveTableName, pk, rk)}
// Backend contract (RestoreTablePreflightValidator) allows empty PK/RK
// — only null is rejected. Match that here so an Azure Table row with
// a literal empty key (legal per spec) can be previewed/restored.
disabled={disabled || !tableName}
disabled={disabled || !effectiveTableName}
className="px-3 py-1.5 bg-green-600 hover:bg-green-700 disabled:opacity-50 text-white text-sm rounded-md transition-colors"
>
Preview restore
@@ -44,7 +44,10 @@ export default function BackupsListPage() {
}, [getAccessToken, setError]);
useEffect(() => {
void load();
const run = async () => {
await load();
};
void run();
}, [load]);
// Poll the active job until terminal, then refresh the list.
@@ -57,7 +60,10 @@ export default function BackupsListPage() {
activeJob.state === "BlockedTerminal"
) {
// Terminal — final refresh and stop polling.
void load();
const run = async () => {
await load();
};
void run();
return;
}
@@ -135,10 +135,13 @@ function DeviceBlockSectionInner({
const reasonParam = searchParams?.get("reason");
if (!sessionIdParam) return;
autoResolvedRef.current = true;
setBlockSessionId(sessionIdParam);
if (actionParam === "Block" || actionParam === "Kill") setBlockAction(actionParam);
if (reasonParam) setBlockReason(reasonParam);
void handleResolveSession(sessionIdParam);
const run = async () => {
setBlockSessionId(sessionIdParam);
if (actionParam === "Block" || actionParam === "Kill") setBlockAction(actionParam);
if (reasonParam) setBlockReason(reasonParam);
await handleResolveSession(sessionIdParam);
};
void run();
// handleResolveSession intentionally excluded from deps — fires once per mount based on URL params
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [searchParams]);
@@ -168,7 +168,10 @@ export function DistressReportsSection({
};
useEffect(() => {
fetchReports();
const run = async () => {
await fetchReports();
};
void run();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
@@ -74,7 +74,10 @@ export function FeedbackSection({ getAccessToken, setError }: FeedbackSectionPro
}, [getAccessToken, setError]);
useEffect(() => {
fetchFeedback();
const run = async () => {
await fetchFeedback();
};
void run();
}, [fetchFeedback]);
// Partition by tab. Server returns mixed; client splits + filters per active tab so the
@@ -56,19 +56,13 @@ export function IgnoredSoftwareTab({
}
}, [getAccessToken, setError, onCountChanged]);
// Lazy load on mount
// Fetch on mount and refetch when refreshTrigger changes (e.g. unmapped tab ignored an item)
useEffect(() => {
if (!ignoredLoaded) {
fetchIgnoredSoftware();
}
const run = async () => {
await fetchIgnoredSoftware();
};
void run();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [ignoredLoaded]);
// Reset when refreshTrigger changes (e.g. unmapped tab ignored an item)
useEffect(() => {
if (refreshTrigger > 0) {
setIgnoredLoaded(false);
}
}, [refreshTrigger]);
// --- Handlers ---
@@ -83,7 +83,10 @@ export function MappedSoftwareTab({
// Lazy load on first render
useEffect(() => {
if (!mappedLoaded) {
fetchCpeMappings();
const run = async () => {
await fetchCpeMappings();
};
void run();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [mappedLoaded]);
@@ -101,6 +104,14 @@ export function MappedSoftwareTab({
const getMappedRowKey = (entry: CpeMappingEntry): string =>
`${entry.source}::${entry.normalizedVendor}::${entry.normalizedProduct}`;
// Jump back to page 0 when the search changes (adjust-during-render pattern,
// see react.dev "storing information from previous renders").
const [prevSearchQuery, setPrevSearchQuery] = useState(searchQuery);
if (prevSearchQuery !== searchQuery) {
setPrevSearchQuery(searchQuery);
setMappedPage(0);
}
const filteredMappedEntries = mappedEntries.filter((m) => {
if (!searchQuery.trim()) return true;
const q = searchQuery.toLowerCase();
@@ -123,11 +134,6 @@ export function MappedSoftwareTab({
(mappedPage + 1) * pageSize
);
// Reset page when search changes
useEffect(() => {
setMappedPage(0);
}, [searchQuery]);
const getSourceBadge = (source: string) => {
switch (source) {
case "custom":
@@ -1,6 +1,6 @@
"use client";
import { useState, useEffect } from "react";
import { useState } from "react";
import { type OpsAlertRule } from "../AdminConfigContext";
import { AUTO_ACTION_MODES, describeAutoActionWarning, type AutoActionMode } from "./excessiveEventAutoAction";
@@ -146,6 +146,19 @@ const CATEGORY_COLORS: Record<string, string> = {
Platform: "text-sky-700 dark:text-sky-300",
};
// Full rule list: merge saved rules with all known event types
function buildFullRules(savedRules: OpsAlertRule[]): OpsAlertRule[] {
const ruleMap = new Map(savedRules.map(r => [r.eventType, r]));
const fullRules: OpsAlertRule[] = [];
for (const eventTypes of Object.values(OPS_EVENT_TYPES)) {
for (const et of eventTypes) {
const existing = ruleMap.get(et);
fullRules.push(existing ?? { eventType: et, minSeverity: "Error", enabled: false });
}
}
return fullRules;
}
interface OpsAlertRulesSectionProps {
loadingConfig: boolean;
savingOpsAlerts: boolean;
@@ -190,31 +203,27 @@ export function OpsAlertRulesSection({
excessiveEventAutoActionDurationHours: autoActionDurationProp,
onSave,
}: OpsAlertRulesSectionProps) {
// Local state for editing
const [rules, setRules] = useState<OpsAlertRule[]>([]);
const [telegramEnabled, setTelegramEnabled] = useState(false);
const [telegramChatId, setTelegramChatId] = useState("");
const [teamsEnabled, setTeamsEnabled] = useState(false);
const [teamsWebhookUrl, setTeamsWebhookUrl] = useState("");
const [slackEnabled, setSlackEnabled] = useState(false);
const [slackWebhookUrl, setSlackWebhookUrl] = useState("");
// Local state for editing, seeded from props
const [rules, setRules] = useState<OpsAlertRule[]>(() => buildFullRules(opsAlertRules));
const [telegramEnabled, setTelegramEnabled] = useState(opsAlertTelegramEnabled);
const [telegramChatId, setTelegramChatId] = useState(opsAlertTelegramChatId);
const [teamsEnabled, setTeamsEnabled] = useState(opsAlertTeamsEnabled);
const [teamsWebhookUrl, setTeamsWebhookUrl] = useState(opsAlertTeamsWebhookUrl);
const [slackEnabled, setSlackEnabled] = useState(opsAlertSlackEnabled);
const [slackWebhookUrl, setSlackWebhookUrl] = useState(opsAlertSlackWebhookUrl);
const [excessiveThreshold, setExcessiveThreshold] = useState(excessiveEventCountThresholdProp);
const [autoActionMode, setAutoActionMode] = useState<AutoActionMode>(autoActionModeProp);
const [autoActionThreshold, setAutoActionThreshold] = useState(autoActionThresholdProp);
const [autoActionDuration, setAutoActionDuration] = useState(autoActionDurationProp);
// Sync from props
useEffect(() => {
// Build full rule list: merge saved rules with all known event types
const ruleMap = new Map(opsAlertRules.map(r => [r.eventType, r]));
const fullRules: OpsAlertRule[] = [];
for (const eventTypes of Object.values(OPS_EVENT_TYPES)) {
for (const et of eventTypes) {
const existing = ruleMap.get(et);
fullRules.push(existing ?? { eventType: et, minSeverity: "Error", enabled: false });
}
}
setRules(fullRules);
// Re-seed the local draft whenever a synced prop changes (config load/save),
// replicating the previous sync effect's dependency-array comparison via the
// adjust-during-render pattern (react.dev "storing information from previous renders").
const syncedProps = [opsAlertRules, opsAlertTelegramEnabled, opsAlertTelegramChatId, opsAlertTeamsEnabled, opsAlertTeamsWebhookUrl, opsAlertSlackEnabled, opsAlertSlackWebhookUrl, excessiveEventCountThresholdProp, autoActionModeProp, autoActionThresholdProp, autoActionDurationProp];
const [prevSyncedProps, setPrevSyncedProps] = useState(syncedProps);
if (syncedProps.some((value, i) => !Object.is(value, prevSyncedProps[i]))) {
setPrevSyncedProps(syncedProps);
setRules(buildFullRules(opsAlertRules));
setTelegramEnabled(opsAlertTelegramEnabled);
setTelegramChatId(opsAlertTelegramChatId);
setTeamsEnabled(opsAlertTeamsEnabled);
@@ -225,7 +234,7 @@ export function OpsAlertRulesSection({
setAutoActionMode(autoActionModeProp);
setAutoActionThreshold(autoActionThresholdProp);
setAutoActionDuration(autoActionDurationProp);
}, [opsAlertRules, opsAlertTelegramEnabled, opsAlertTelegramChatId, opsAlertTeamsEnabled, opsAlertTeamsWebhookUrl, opsAlertSlackEnabled, opsAlertSlackWebhookUrl, excessiveEventCountThresholdProp, autoActionModeProp, autoActionThresholdProp, autoActionDurationProp]);
}
const toggleRule = (eventType: string) => {
setRules(prev => prev.map(r => r.eventType === eventType ? { ...r, enabled: !r.enabled } : r));
@@ -179,10 +179,13 @@ export function OpsEventsSection({
// re-fire after every successful page-N click, race a page-1 fetch against
// it, and snap the user back to page 1.
useEffect(() => {
setContinuation(null);
setContinuationStack([]);
setPageNumber(1);
fetchEvents(null);
const run = async () => {
setContinuation(null);
setContinuationStack([]);
setPageNumber(1);
await fetchEvents(null);
};
void run();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [categoryFilter, dateFromIso, dateToIso]);
@@ -1,6 +1,6 @@
"use client";
import { Suspense, useCallback, useEffect, useRef, useState } from "react";
import { Suspense, useCallback, useEffect, useState } from "react";
import { useSearchParams } from "next/navigation";
import { api } from "@/lib/api";
import { authenticatedFetch, TokenExpiredError } from "@/lib/authenticatedFetch";
@@ -77,6 +77,113 @@ function CopyButton({ value }: { value: string }) {
);
}
function AdminNoteEditor({
report,
canMutate,
getAccessToken,
onSaved,
}: {
report: SessionReport;
canMutate: boolean;
getAccessToken: () => Promise<string | null>;
onSaved: (updated: SessionReport) => void;
}) {
const [adminNoteValue, setAdminNoteValue] = useState(report.adminNote ?? "");
const [savingNote, setSavingNote] = useState(false);
const [noteSaveResult, setNoteSaveResult] = useState<"saved" | string | null>(null);
// The parent swaps in a fresh report object after a successful save; mirror the
// previous reset-on-selection semantics for that same-report identity change
// (cross-report switches remount this editor via key={reportId}). Adjust-during-
// render pattern, see react.dev "storing information from previous renders".
const [prevReport, setPrevReport] = useState(report);
if (prevReport !== report) {
setPrevReport(report);
setAdminNoteValue(report.adminNote ?? "");
setNoteSaveResult(null);
}
const handleSaveAdminNote = async () => {
try {
setSavingNote(true);
setNoteSaveResult(null);
const res = await authenticatedFetch(
api.reports.note(report.reportId),
getAccessToken,
{
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ adminNote: adminNoteValue }),
}
);
if (!res.ok) throw new Error(`Failed to save note: ${res.statusText}`);
// Update parent state
onSaved({ ...report, adminNote: adminNoteValue });
setNoteSaveResult("saved");
} catch (err) {
if (err instanceof TokenExpiredError) {
console.error("Session expired while saving admin note");
}
setNoteSaveResult(err instanceof Error ? err.message : "Failed to save note");
} finally {
setSavingNote(false);
}
};
return (
<div className="pt-1 border-t border-gray-100 dark:border-gray-700">
<dt className="font-medium text-gray-500 dark:text-gray-400 flex items-center gap-1.5">
<svg className="w-3.5 h-3.5" fill="currentColor" viewBox="0 0 24 24">
<path d="M3 17.25V21h3.75L17.81 9.94l-3.75-3.75L3 17.25zM20.71 7.04a1 1 0 000-1.41l-2.34-2.34a1 1 0 00-1.41 0l-1.83 1.83 3.75 3.75 1.83-1.83z" />
</svg>
Admin Note
</dt>
<dd className="mt-1.5">
<textarea
value={adminNoteValue}
onChange={e => { setAdminNoteValue(e.target.value); setNoteSaveResult(null); }}
readOnly={!canMutate}
rows={3}
placeholder={canMutate ? "Add an internal note about this report..." : "No note"}
className="w-full text-sm rounded-md border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 px-3 py-2 focus:outline-none focus:ring-2 focus:ring-green-500 resize-none placeholder:text-gray-400 read-only:opacity-60 read-only:cursor-not-allowed"
/>
<div className="flex items-center justify-between mt-1.5">
<div className="text-xs">
{noteSaveResult === "saved" && (
<span className="text-green-600 dark:text-green-400 flex items-center gap-1">
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
Saved
</span>
)}
{noteSaveResult && noteSaveResult !== "saved" && (
<span className="text-red-600 dark:text-red-400">{noteSaveResult}</span>
)}
</div>
<button
onClick={handleSaveAdminNote}
disabled={!canMutate || savingNote}
className="flex items-center gap-1.5 px-3 py-1.5 bg-green-600 hover:bg-green-700 disabled:bg-indigo-400 text-white rounded-md transition-colors text-xs font-medium disabled:cursor-not-allowed"
>
{savingNote ? (
<>
<svg className="animate-spin h-3 w-3" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
Saving...
</>
) : "Save Note"}
</button>
</div>
</dd>
</div>
);
}
export function SessionReportsSection(props: SessionReportsSectionProps) {
// useSearchParams() in the inner component requires a Suspense boundary for
// static prerender (this section renders inside the prerendered
@@ -101,9 +208,6 @@ function SessionReportsSectionInner({
const [loading, setLoading] = useState(true);
const [selectedReport, setSelectedReport] = useState<SessionReport | null>(null);
const [downloadingBlob, setDownloadingBlob] = useState<string | null>(null);
const [adminNoteValue, setAdminNoteValue] = useState("");
const [savingNote, setSavingNote] = useState(false);
const [noteSaveResult, setNoteSaveResult] = useState<"saved" | string | null>(null);
// Pattern B1 click-next replace state — backend pagination
const [tenantFilterInput, setTenantFilterInput] = useState("");
@@ -114,30 +218,34 @@ function SessionReportsSectionInner({
const [pageNumber, setPageNumber] = useState(1);
// Deep link from GA notifications: ?reportId=… auto-opens the report modal once
// the first page has loaded. Reports are listed newest-first, so a report reached
// via a fresh notification is on page 1; older ones get a hint instead.
// the first page has loaded (one-shot). Reports are listed newest-first, so a
// report reached via a fresh notification is on page 1; older ones get a hint
// instead. Adjust-during-render (react.dev "storing information from previous
// renders"); the miss branch only records the id here because the hint lives in
// PARENT state, which must not be set during this component's render — the
// effect below routes it to setError.
const searchParams = useSearchParams();
const autoOpenedRef = useRef(false);
useEffect(() => {
if (autoOpenedRef.current || loading) return;
const reportIdParam = searchParams?.get("reportId");
if (!reportIdParam) return;
autoOpenedRef.current = true;
const reportIdParam = searchParams?.get("reportId") ?? null;
const [autoOpenConsumed, setAutoOpenConsumed] = useState(false);
const [autoOpenMissId, setAutoOpenMissId] = useState<string | null>(null);
if (!autoOpenConsumed && !loading && reportIdParam) {
setAutoOpenConsumed(true);
const match = reports.find((r) => r.reportId === reportIdParam);
if (match) {
setSelectedReport(match);
} else {
setError(`Report ${reportIdParam} is not on the first page — it may be older. Page through or filter by tenant to find it.`);
setAutoOpenMissId(reportIdParam);
}
}, [loading, reports, searchParams, setError]);
}
useEffect(() => {
if (selectedReport) {
setAdminNoteValue(selectedReport.adminNote ?? "");
setNoteSaveResult(null);
}
}, [selectedReport]);
if (autoOpenMissId === null) return;
setError(`Report ${autoOpenMissId} is not on the first page — it may be older. Page through or filter by tenant to find it.`);
// setError intentionally excluded from deps: the hint fires once per consumed
// deep-link miss (autoOpenMissId is set at most once) and must not re-surface
// after dismissal just because the parent's setError identity churns.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [autoOpenMissId]);
const handleDownload = async (blobName: string) => {
try {
@@ -166,36 +274,9 @@ function SessionReportsSectionInner({
}
};
const handleSaveAdminNote = async () => {
if (!selectedReport) return;
try {
setSavingNote(true);
setNoteSaveResult(null);
const res = await authenticatedFetch(
api.reports.note(selectedReport.reportId),
getAccessToken,
{
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ adminNote: adminNoteValue }),
}
);
if (!res.ok) throw new Error(`Failed to save note: ${res.statusText}`);
// Update local state
const updated = { ...selectedReport, adminNote: adminNoteValue };
setSelectedReport(updated);
setReports(prev => prev.map(r => r.reportId === selectedReport.reportId ? updated : r));
setNoteSaveResult("saved");
} catch (err) {
if (err instanceof TokenExpiredError) {
console.error("Session expired while saving admin note");
}
setNoteSaveResult(err instanceof Error ? err.message : "Failed to save note");
} finally {
setSavingNote(false);
}
const handleNoteSaved = (updated: SessionReport) => {
setSelectedReport(updated);
setReports(prev => prev.map(r => r.reportId === updated.reportId ? updated : r));
};
const fetchReports = useCallback(async (cursor: string | null, filterTenantId: string | undefined) => {
@@ -238,10 +319,13 @@ function SessionReportsSectionInner({
// re-fire after every successful page-N click, race a page-1 fetch against
// it, and snap the user back to page 1.
useEffect(() => {
setContinuation(null);
setContinuationStack([]);
setPageNumber(1);
fetchReports(null, tenantFilterApplied);
const run = async () => {
setContinuation(null);
setContinuationStack([]);
setPageNumber(1);
await fetchReports(null, tenantFilterApplied);
};
void run();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [tenantFilterApplied]);
@@ -530,55 +614,15 @@ function SessionReportsSectionInner({
</div>
)}
{/* Admin Note */}
<div className="pt-1 border-t border-gray-100 dark:border-gray-700">
<dt className="font-medium text-gray-500 dark:text-gray-400 flex items-center gap-1.5">
<svg className="w-3.5 h-3.5" fill="currentColor" viewBox="0 0 24 24">
<path d="M3 17.25V21h3.75L17.81 9.94l-3.75-3.75L3 17.25zM20.71 7.04a1 1 0 000-1.41l-2.34-2.34a1 1 0 00-1.41 0l-1.83 1.83 3.75 3.75 1.83-1.83z" />
</svg>
Admin Note
</dt>
<dd className="mt-1.5">
<textarea
value={adminNoteValue}
onChange={e => { setAdminNoteValue(e.target.value); setNoteSaveResult(null); }}
readOnly={!canMutate}
rows={3}
placeholder={canMutate ? "Add an internal note about this report..." : "No note"}
className="w-full text-sm rounded-md border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 px-3 py-2 focus:outline-none focus:ring-2 focus:ring-green-500 resize-none placeholder:text-gray-400 read-only:opacity-60 read-only:cursor-not-allowed"
/>
<div className="flex items-center justify-between mt-1.5">
<div className="text-xs">
{noteSaveResult === "saved" && (
<span className="text-green-600 dark:text-green-400 flex items-center gap-1">
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
Saved
</span>
)}
{noteSaveResult && noteSaveResult !== "saved" && (
<span className="text-red-600 dark:text-red-400">{noteSaveResult}</span>
)}
</div>
<button
onClick={handleSaveAdminNote}
disabled={!canMutate || savingNote}
className="flex items-center gap-1.5 px-3 py-1.5 bg-green-600 hover:bg-green-700 disabled:bg-indigo-400 text-white rounded-md transition-colors text-xs font-medium disabled:cursor-not-allowed"
>
{savingNote ? (
<>
<svg className="animate-spin h-3 w-3" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
Saving...
</>
) : "Save Note"}
</button>
</div>
</dd>
</div>
{/* Admin Note — keyed on the report so switching reports remounts
the editor and re-seeds its draft from the report's saved note. */}
<AdminNoteEditor
key={selectedReport.reportId}
report={selectedReport}
canMutate={canMutate}
getAccessToken={getAccessToken}
onSaved={handleNoteSaved}
/>
</dl>
<div className="mt-6 flex items-center justify-between">
@@ -68,7 +68,10 @@ export function TenantAdminSection({
};
useEffect(() => {
fetchTenantAdmins(tenantId);
const run = async () => {
await fetchTenantAdmins(tenantId);
};
void run();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [tenantId]);
@@ -1,6 +1,6 @@
"use client";
import { Suspense, useEffect, useRef, useState } from "react";
import { Suspense, useState } from "react";
import { useSearchParams } from "next/navigation";
import { api } from "@/lib/api";
import { authenticatedFetch, TokenExpiredError } from "@/lib/authenticatedFetch";
@@ -104,22 +104,21 @@ function TenantManagementSectionInner({
}: TenantManagementSectionProps) {
// Read-only Global Readers may view tenants (incl. config report) but not edit them.
const canMutate = useCanMutatePlatform();
const [searchQuery, setSearchQuery] = useState("");
// Deep link from GA notifications: ?tenantId=… seeds the search box once, so the
// list opens filtered to the tenant the notification is about. Seeded via the
// useState initializer when the param is present at mount; the adjust-during-render
// block below covers a param that only appears on a later soft navigation.
const searchParams = useSearchParams();
const tenantIdParam = searchParams?.get("tenantId") ?? null;
const [searchQuery, setSearchQuery] = useState(() => tenantIdParam ?? "");
const [seededSearch, setSeededSearch] = useState(tenantIdParam !== null);
if (!seededSearch && tenantIdParam) {
setSeededSearch(true);
setSearchQuery(tenantIdParam);
}
// Mount-time clock for trial-expiry checks — render must stay pure
// (react-hooks/purity); day-granularity expiry doesn't need a live clock.
const [nowMs] = useState(() => Date.now());
// Deep link from GA notifications: ?tenantId=… seeds the search box once, so the
// list opens filtered to the tenant the notification is about.
const searchParams = useSearchParams();
const seededSearchRef = useRef(false);
useEffect(() => {
if (seededSearchRef.current) return;
const tenantIdParam = searchParams?.get("tenantId");
if (!tenantIdParam) return;
seededSearchRef.current = true;
setSearchQuery(tenantIdParam);
}, [searchParams]);
const [showOnlyWaitlist, setShowOnlyWaitlist] = useState(false);
const [showOnlyReady, setShowOnlyReady] = useState(false);
const [tenantSectionExpanded, setTenantSectionExpanded] = useState(false);
@@ -174,10 +173,13 @@ function TenantManagementSectionInner({
const endIndex = startIndex + tenantsPerPage;
const paginatedTenants = filteredTenants.slice(startIndex, endIndex);
// Reset to first page when search changes
useEffect(() => {
// Reset to first page when the search or a filter changes (adjust-during-render
// pattern, see react.dev "storing information from previous renders").
const [prevFilterKey, setPrevFilterKey] = useState<[string, boolean, boolean]>([searchQuery, showOnlyWaitlist, showOnlyReady]);
if (prevFilterKey[0] !== searchQuery || prevFilterKey[1] !== showOnlyWaitlist || prevFilterKey[2] !== showOnlyReady) {
setPrevFilterKey([searchQuery, showOnlyWaitlist, showOnlyReady]);
setCurrentPage(0);
}, [searchQuery, showOnlyWaitlist, showOnlyReady]);
}
const handleSaveTenant = async (tenant: TenantConfiguration) => {
if (!canMutate) return; // read-only Global Reader
@@ -112,17 +112,23 @@ export function UnmappedSoftwareTab({
// Load on mount + whenever the page index changes
useEffect(() => {
fetchPage(currentPage);
const run = async () => {
await fetchPage(currentPage);
};
void run();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [currentPage]);
// Refresh when parent signals (e.g. item restored from ignored tab) — reset to page 0
useEffect(() => {
if (refreshTrigger > 0) {
setSavedMappings(new Set());
setSelectedEntries(new Map());
if (currentPage !== 0) setCurrentPage(0);
else fetchPage(0);
const run = async () => {
setSavedMappings(new Set());
setSelectedEntries(new Map());
if (currentPage !== 0) setCurrentPage(0);
else await fetchPage(0);
};
void run();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [refreshTrigger]);
@@ -96,7 +96,10 @@ export function VulnerabilityDataSection({
}, [getAccessToken]);
useEffect(() => {
void refreshSyncStatus();
const run = async () => {
await refreshSyncStatus();
};
void run();
}, [refreshSyncStatus]);
const refreshSummary = useCallback(async () => {
@@ -120,7 +123,10 @@ export function VulnerabilityDataSection({
}, [getAccessToken, summaryDays]);
useEffect(() => {
void refreshSummary();
const run = async () => {
await refreshSummary();
};
void run();
}, [refreshSummary]);
// Sync local state when adminConfig changes (initial load)
@@ -1,6 +1,6 @@
"use client";
import { useEffect, useState } from "react";
import { useState } from "react";
interface DeleteConfirmModalProps {
open: boolean;
@@ -19,8 +19,14 @@ const CONFIRM_WORD = "DELETE";
* (tenant id, table/row, count) and require a typed "DELETE" before the
* primary button activates.
*/
export function DeleteConfirmModal({
open,
export function DeleteConfirmModal(props: DeleteConfirmModalProps) {
// The dialog content only mounts while open, so the typed confirmation text
// resets naturally on close (unmount) — no state sync needed.
if (!props.open) return null;
return <DeleteConfirmModalContent {...props} />;
}
function DeleteConfirmModalContent({
title,
description,
busy = false,
@@ -29,12 +35,6 @@ export function DeleteConfirmModal({
}: DeleteConfirmModalProps) {
const [text, setText] = useState("");
useEffect(() => {
if (!open) setText("");
}, [open]);
if (!open) return null;
const canConfirm = text === CONFIRM_WORD && !busy;
return (
@@ -58,7 +58,10 @@ function CustomsArchiveDetailContent() {
}, [tenantId, historyRowKey, getAccessToken, setError]);
useEffect(() => {
void load();
const run = async () => {
await load();
};
void run();
}, [load]);
const toggleExpand = useCallback(async (entry: CustomsArchiveEntrySummary) => {
@@ -43,7 +43,10 @@ export default function CustomsArchivePage() {
}, [filter, getAccessToken, setError]);
useEffect(() => {
void load();
const run = async () => {
await load();
};
void run();
}, [load]);
const confirmDelete = useCallback(async () => {
@@ -225,7 +225,10 @@ export function SectionAgentMetrics() {
// accounts refresh which would otherwise re-fire the effect after each
// request and ping-pong the loading spinner.
useEffect(() => {
fetchMetrics(sampleSize, windowDays);
const run = async () => {
await fetchMetrics(sampleSize, windowDays);
};
void run();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [sampleSize, windowDays]);
@@ -90,7 +90,10 @@ export function SectionMcpUsage() {
}, [getAccessToken]);
useEffect(() => {
fetchData(dateRange);
const run = async () => {
await fetchData(dateRange);
};
void run();
}, [fetchData, dateRange]);
// Aggregate by user
@@ -156,7 +156,10 @@ export function SectionPlatformUsage() {
useEffect(() => {
// Fetch metrics on mount
// Authorization is handled by admin layout ProtectedRoute
fetchMetrics();
const run = async () => {
await fetchMetrics();
};
void run();
}, [fetchMetrics]);
const formatDuration = (minutes: number) => {
@@ -62,10 +62,10 @@ export function DeletionManifestSummaryView({
useEffect(() => {
let cancelled = false;
setLoading(true);
setError(null);
setSummary(null);
(async () => {
setLoading(true);
setError(null);
setSummary(null);
try {
const resp = await authenticatedFetch(
api.sessionDeletions.storedManifest(sessionId, tenantId, manifestId, "summary"),
@@ -120,7 +120,10 @@ export function MaintenanceStatusBanner({
}, [getAccessToken, setError]);
useEffect(() => {
void fetchStatus();
const run = async () => {
await fetchStatus();
};
void run();
}, [fetchStatus, refreshKey]);
const triggerRun = useCallback(async () => {
@@ -85,8 +85,8 @@ export function RestoreBrowserTab({ getAccessToken, setError, setSuccessMessage
if (!onlyWithRestore || tenantsWithRestore !== null) return;
const controller = new AbortController();
let cancelled = false;
setLoadingRestoreFilter(true);
(async () => {
setLoadingRestoreFilter(true);
try {
const resp = await authenticatedFetch(
api.sessionDeletions.tenantsWithManifests(),
@@ -142,23 +142,24 @@ export function RestoreBrowserTab({ getAccessToken, setError, setSuccessMessage
// the operator never sees stale manifests / preview / restore-action that belong to the
// previous tenant. Codex P2.
useEffect(() => {
// Immediate clear: dropping the displayed data on switch means the operator never has a
// "this manifest from tenant A is selected while the dropdown says tenant B" moment.
setSessions(null);
setExpandedSessions(new Set());
setSelection(null);
setLoadedTenantId("");
if (!selectedTenantId) {
setLoadingSessions(false);
return;
}
const controller = new AbortController();
let cancelled = false;
setLoadingSessions(true);
(async () => {
// Immediate clear: dropping the displayed data on switch means the operator never has a
// "this manifest from tenant A is selected while the dropdown says tenant B" moment.
setSessions(null);
setExpandedSessions(new Set());
setSelection(null);
setLoadedTenantId("");
if (!selectedTenantId) {
setLoadingSessions(false);
return;
}
setLoadingSessions(true);
try {
const resp = await authenticatedFetch(
api.sessionDeletions.tenantManifests(selectedTenantId),
@@ -176,7 +176,10 @@ function InFlightTab({
}, [getAccessToken, setError]);
useEffect(() => {
void fetchAll();
const run = async () => {
await fetchAll();
};
void run();
}, [fetchAll, refreshKey]);
return (
@@ -253,7 +256,10 @@ function PoisonedTab({
}, [getAccessToken, setError]);
useEffect(() => {
void fetchPoisoned();
const run = async () => {
await fetchPoisoned();
};
void run();
}, [fetchPoisoned, refreshKey]);
return (
@@ -353,7 +359,10 @@ function StrandedTab({
}, [getAccessToken, setError]);
useEffect(() => {
void fetchStranded();
const run = async () => {
await fetchStranded();
};
void run();
}, [fetchStranded, refreshKey]);
return (
@@ -92,7 +92,10 @@ export default function PresencePage() {
// Initial load + whenever the window changes.
useEffect(() => {
fetchPresence();
const run = async () => {
await fetchPresence();
};
void run();
}, [fetchPresence, windowMinutes]);
// Auto-refresh on a fixed interval (reads the current window via ref).
@@ -80,7 +80,10 @@ export function SectionDelegatedAdmins() {
}, [getAccessToken]);
useEffect(() => {
fetchAssignments();
const run = async () => {
await fetchAssignments();
};
void run();
}, [fetchAssignments]);
const handleGrant = useCallback(async () => {
@@ -89,7 +89,10 @@ export function SectionTenantGroups() {
}, [getAccessToken]);
useEffect(() => {
fetchGroups();
const run = async () => {
await fetchGroups();
};
void run();
}, [fetchGroups]);
/** Runs a mutation with shared busy/error/refetch handling. `body` undefined ⇒ no JSON body. */
@@ -57,7 +57,10 @@ export function SectionUsagePlans() {
}, [getAccessToken]);
useEffect(() => {
fetchTiers();
const run = async () => {
await fetchTiers();
};
void run();
}, [fetchTiers]);
const handleSave = useCallback(async () => {
@@ -19,6 +19,7 @@ interface TenantConfig {
lastUpdated?: string;
updatedBy?: string;
onboardedAt?: string;
contactEmail?: string | null;
// Tenant status
disabled?: boolean;
@@ -405,7 +406,10 @@ export function SectionTenantConfigReport() {
}, [selectedTenantId, getAccessToken]);
useEffect(() => {
fetchConfig();
const run = async () => {
await fetchConfig();
};
void run();
}, [fetchConfig]);
// Computed runtime
@@ -536,6 +540,7 @@ export function SectionTenantConfigReport() {
<ConfigRow label="Disabled Reason" value={config.disabledReason} />
<ConfigRow label="Disabled Until" value={config.disabledUntil} isDate />
<ConfigRow label="Onboarded At" value={config.onboardedAt} isDate />
<ConfigRow label="Contact Email" value={config.contactEmail} />
<ConfigRow
label="Plan Tier"
value={`${config.planTier || 'free'}${config.trialExpiresUtc && new Date(config.trialExpiresUtc).getTime() > nowMs ? ` (trial until ${formatDate(config.trialExpiresUtc)})` : ''}`}
@@ -0,0 +1,261 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import Link from "next/link";
import { sessionUrl } from "@/lib/routes";
import { api } from "@/lib/api";
import { API_BASE_URL } from "@/utils/config";
import { authenticatedFetch } from "@/lib/authenticatedFetch";
import { ProtectedRoute } from "@/components/ProtectedRoute";
import { useAuth } from "@/contexts/AuthContext";
import { useAggregatedAdminScope } from "@/hooks";
import { TenantScopeSelector } from "@/components/TenantScopeSelector";
import { GlobalAdminBanner, globalAdminSubtitle } from "@/components/GlobalAdminBanner";
import {
ANNOTATION_VERDICTS,
LANE_LABELS,
VERDICT_DESCRIPTIONS,
VERDICT_LABELS,
VERDICT_PILL_CLASSES,
visibleLanes,
type AnnotationLane,
type AnnotationVerdict,
} from "../sessions/components/sessionAnnotationLogic";
/**
* Annotations overview: every annotated session in one list, so a judged session can be
* found again without remembering which one it was. Server-side filters (verdict, lane)
* + nextLink pagination; rows deep-link into the session's annotation section. The
* backend excludes the platform-internal globaladmin lane for tenant callers.
*
* Cross-tenant: a GA/Reader gets the tenant switcher incl. the "All tenants" aggregate
* (the same evaluation stream MCP's list_session_annotations serves); a delegated
* ("MSP") admin gets the switcher bounded to its managed tenants.
*/
interface AnnotationRow {
tenantId?: string | null;
sessionId: string;
lane: string;
verdict?: string | null;
note?: string | null;
authorDisplayName?: string | null;
updatedAtUtc?: string | null;
ruleIds?: string[] | null;
}
interface AnnotationsListResponse {
success: boolean;
annotations?: AnnotationRow[] | null;
nextLink?: string | null;
}
export default function AnnotationsPage() {
const { getAccessToken, user } = useAuth();
const [rows, setRows] = useState<AnnotationRow[]>([]);
const [nextLink, setNextLink] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const [loadError, setLoadError] = useState<string | null>(null);
const [verdictFilter, setVerdictFilter] = useState("");
const [laneFilter, setLaneFilter] = useState("");
// GA/Reader: "All tenants" aggregate by default (mirrors the MCP evaluation stream) with
// per-tenant drill-down; delegated ("MSP"): managed tenants only, never aggregated.
const scope = useAggregatedAdminScope({ defaultAggregated: true });
const { isGlobalAdmin: crossTenant, routeGlobal, selectedTenantId, scopeInitialized, tenants } = scope;
const lanes = visibleLanes(user);
const tenantLabel = (id: string | null | undefined) =>
tenants.find((t) => t.tenantId === id)?.domainName ?? id ?? "—";
const fetchPage = useCallback(
async (url: string, append: boolean) => {
try {
const res = await authenticatedFetch(url, getAccessToken);
if (res.status === 404) {
// Deploy skew: a backend without the route yet. Reads as "nothing annotated",
// never as an error — the empty state below carries the call to action.
if (!append) setRows([]);
setNextLink(null);
return;
}
if (!res.ok) throw new Error(res.statusText);
const json = (await res.json()) as AnnotationsListResponse;
setRows((prev) => (append ? [...prev, ...(json.annotations ?? [])] : (json.annotations ?? [])));
setNextLink(json.nextLink ?? null);
} catch {
// Non-technical by design — the status text means nothing to the reader.
setLoadError("Annotations could not be loaded right now — please try again in a moment.");
} finally {
setLoading(false);
}
},
[getAccessToken]
);
useEffect(() => {
// Wait for the scope's default selection to settle so we never fire a request in the
// wrong scope. Inner async wrapper: set-state-in-effect flags a direct call to a
// state-setting callback even when every setState sits behind an await.
if (!scopeInitialized) return;
const loadFirstPage = async () => {
setLoading(true);
setLoadError(null);
const filters = {
verdict: verdictFilter || undefined,
lane: laneFilter || undefined,
};
await fetchPage(
routeGlobal
? api.annotations.globalList({ ...filters, tenantId: selectedTenantId || undefined })
: api.annotations.list(filters),
false
);
};
void loadFirstPage();
}, [fetchPage, verdictFilter, laneFilter, scopeInitialized, routeGlobal, selectedTenantId]);
return (
<ProtectedRoute>
<div className="min-h-screen bg-gray-50">
<GlobalAdminBanner
show={crossTenant}
delegated={scope.isDelegatedScope}
subtitle={globalAdminSubtitle(scope, "aggregating annotations across all tenants")}
/>
<header className="bg-white shadow">
<div className="py-6 px-4 sm:px-6 lg:px-8">
<div className="flex flex-wrap items-center justify-between gap-y-3">
<div>
<h1 className="text-2xl font-normal text-gray-900">Annotations</h1>
<p className="mt-1 text-sm text-gray-500">
Every session your team has judged — open one to read or update the verdict.
</p>
</div>
<TenantScopeSelector scope={scope} allowAggregated />
</div>
</div>
</header>
<main className="py-6 px-4 sm:px-6 lg:px-8">
<div className="bg-white shadow rounded-lg p-6">
<div className="flex flex-wrap items-center gap-3 mb-4">
<select
value={verdictFilter}
onChange={(e) => setVerdictFilter(e.target.value)}
className="border border-gray-300 rounded-md px-3 py-1.5 text-sm text-gray-700 focus:outline-none focus:ring-2 focus:ring-green-500"
aria-label="Filter by verdict"
>
<option value="">All verdicts</option>
{ANNOTATION_VERDICTS.map((v) => (
<option key={v} value={v}>{VERDICT_LABELS[v]}</option>
))}
</select>
<select
value={laneFilter}
onChange={(e) => setLaneFilter(e.target.value)}
className="border border-gray-300 rounded-md px-3 py-1.5 text-sm text-gray-700 focus:outline-none focus:ring-2 focus:ring-green-500"
aria-label="Filter by role"
>
<option value="">All roles</option>
{lanes.map((lane) => (
<option key={lane} value={lane}>{LANE_LABELS[lane]}</option>
))}
</select>
</div>
{loadError ? (
<p className="text-sm text-amber-700">{loadError}</p>
) : rows.length === 0 && !loading ? (
<p className="text-sm text-gray-400">
No annotations yet. Open a session and record a verdict in its{" "}
<span className="font-medium">Annotations</span> section — judged sessions
show up here.
</p>
) : (
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200 text-sm">
<thead>
<tr className="text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
{crossTenant && <th className="px-3 py-2">Tenant</th>}
<th className="px-3 py-2">Verdict</th>
<th className="px-3 py-2">Role</th>
<th className="px-3 py-2">Note</th>
<th className="px-3 py-2">Author</th>
<th className="px-3 py-2">Updated</th>
<th className="px-3 py-2"></th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
{rows.map((row) => (
<tr key={`${row.tenantId ?? ""}_${row.sessionId}_${row.lane}`} className="hover:bg-gray-50">
{crossTenant && (
<td className="px-3 py-2 whitespace-nowrap text-gray-700">
{tenantLabel(row.tenantId)}
</td>
)}
<td className="px-3 py-2 whitespace-nowrap">
{row.verdict != null ? (
<span
title={VERDICT_DESCRIPTIONS[row.verdict as AnnotationVerdict]}
className={`inline-flex px-2 py-0.5 rounded-full text-xs font-medium ${VERDICT_PILL_CLASSES[row.verdict] ?? "bg-gray-100 text-gray-600"}`}
>
{VERDICT_LABELS[row.verdict as AnnotationVerdict] ?? row.verdict}
</span>
) : (
<span className="text-xs text-gray-400">note only</span>
)}
</td>
<td className="px-3 py-2 whitespace-nowrap text-gray-700">
{LANE_LABELS[row.lane as AnnotationLane] ?? row.lane}
</td>
<td className="px-3 py-2 text-gray-700 max-w-md">
<span className="line-clamp-2">{row.note || "—"}</span>
</td>
<td className="px-3 py-2 whitespace-nowrap text-gray-500">
{row.authorDisplayName || "—"}
</td>
<td className="px-3 py-2 whitespace-nowrap text-gray-500">
{row.updatedAtUtc ? new Date(row.updatedAtUtc).toLocaleString() : "—"}
</td>
<td className="px-3 py-2 whitespace-nowrap">
<Link
href={sessionUrl(row.sessionId, {
tenantId: crossTenant ? row.tenantId || undefined : undefined,
hash: "section-annotations",
})}
className="text-green-700 hover:text-green-800 underline underline-offset-2"
>
open session
</Link>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
<div className="flex items-center gap-3 mt-4">
{loading && <span className="text-sm text-gray-400">Loading…</span>}
{!loading && nextLink && (
<button
onClick={() => {
setLoading(true);
setLoadError(null);
fetchPage(`${API_BASE_URL}${nextLink}`, true);
}}
className="px-4 py-1.5 text-sm font-medium bg-white border border-gray-300 rounded-md text-gray-700 hover:bg-gray-50"
>
Load more
</button>
)}
</div>
</div>
</main>
</div>
</ProtectedRoute>
);
}
@@ -165,8 +165,15 @@ export default function InstallsTab({ scope, timeRange }: InstallsTabProps) {
return rows;
}, [data, search, sortKey, sortDir]);
// Reset to the first page whenever the filtered/sorted set or the loaded data changes.
useEffect(() => { setPage(0); }, [search, sortKey, sortDir, data]);
// Reset to the first page whenever the filtered/sorted set or the loaded data
// changes (adjust-during-render pattern, see react.dev "storing information
// from previous renders"). `data` compares by reference, exactly like the
// dependency array of the effect this replaces did.
const [prevPageResetKey, setPrevPageResetKey] = useState<[string, SortKey, SortDir, AppsListResponse | null]>([search, sortKey, sortDir, data]);
if (prevPageResetKey[0] !== search || prevPageResetKey[1] !== sortKey || prevPageResetKey[2] !== sortDir || prevPageResetKey[3] !== data) {
setPrevPageResetKey([search, sortKey, sortDir, data]);
setPage(0);
}
const pageCount = Math.max(1, Math.ceil(filteredAndSorted.length / PAGE_SIZE));
const pageRows = filteredAndSorted.slice(page * PAGE_SIZE, page * PAGE_SIZE + PAGE_SIZE);
@@ -50,10 +50,10 @@ export default function InventoryTab({ scope }: { scope: SoftwareTabScope }) {
useEffect(() => {
if (!scopeInitialized) return;
if (!isGlobalAdmin && !tenantId) return;
if (needsTenantPick) { setResp(null); setLoading(false); return; }
let cancelled = false;
const run = async () => {
if (needsTenantPick) { setResp(null); setLoading(false); return; }
try {
setLoading(true);
const url = routeGlobal
@@ -358,8 +358,11 @@ function AppDetailContent() {
// wrong scope before the GA default-to-own-tenant has settled.
useEffect(() => {
if (!scopeInitialized) return;
setSessionsOffset(0);
Promise.all([fetchAnalytics(), fetchSessions(0, statusFilter)]);
const run = async () => {
setSessionsOffset(0);
await Promise.all([fetchAnalytics(), fetchSessions(0, statusFilter)]);
};
void run();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [scopeInitialized, days, scopeKey]);
@@ -156,10 +156,13 @@ export default function AuditPage() {
// delegated caller) so we don't fire a wasted request in the wrong scope. scopeKey changes on a
// tenant/GA-mode switch → refetch from page 1.
if (!scopeInitialized) return;
setContinuation(null);
setContinuationStack([]);
setPageNumber(1);
fetchPage(null, true);
const run = async () => {
setContinuation(null);
setContinuationStack([]);
setPageNumber(1);
await fetchPage(null, true);
};
void run();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [scopeKey, scopeInitialized, dateFromIso, dateToIso, excludeDeletions]);
@@ -1,6 +1,6 @@
"use client";
import { useState, useEffect } from "react";
import { useEffect, useSyncExternalStore } from "react";
const tips = [
"Auto-set the device timezone under Settings \u2192 Agent Settings.",
@@ -19,12 +19,21 @@ const tips = [
"Spotted a session with the wrong status? Use Report Session to flag it to the team.",
];
export default function TipOfTheDay() {
const [tip, setTip] = useState("");
// One random tip per mount, read via useSyncExternalStore: the server snapshot is ""
// (renders nothing, matching the prerendered HTML — Math.random() at build time would
// freeze one tip and mismatch on hydration); the client re-render then shows the pick.
let chosenTip: string | null = null;
const subscribeNever = () => () => {};
const getTipSnapshot = () => {
if (chosenTip === null) chosenTip = tips[Math.floor(Math.random() * tips.length)];
return chosenTip;
};
useEffect(() => {
setTip(tips[Math.floor(Math.random() * tips.length)]);
}, []);
export default function TipOfTheDay() {
const tip = useSyncExternalStore(subscribeNever, getTipSnapshot, () => "");
// Re-roll on the next mount so each dashboard visit gets a fresh tip.
useEffect(() => () => { chosenTip = null; }, []);
if (!tip) return null;
@@ -1,5 +1,5 @@
"use client";
import { useState, useEffect } from "react";
import { useState } from "react";
import { api } from "@/lib/api";
import { authenticatedFetch, TokenExpiredError } from "@/lib/authenticatedFetch";
import { NotificationType } from "@/contexts/NotificationContext";
@@ -10,6 +10,9 @@ interface BlockTarget {
deviceName?: string;
}
// Module-level so the masked value returned while scope is off is referentially stable.
const EMPTY_SET = new Set<string>();
export function useBlockDevice(
getAccessToken: (forceRefresh?: boolean) => Promise<string | null>,
addNotification: (type: NotificationType, title: string, message: string, key?: string, href?: string) => void,
@@ -21,12 +24,11 @@ export function useBlockDevice(
const [blockingDevice, setBlockingDevice] = useState(false);
const [blockedDevicesSet, setBlockedDevicesSet] = useState<Set<string>>(new Set());
// Reset blocked set when admin mode or global admin mode is turned off
useEffect(() => {
if (!adminMode || !globalAdminMode) {
setBlockedDevicesSet(new Set());
}
}, [adminMode, globalAdminMode]);
// Blocked devices are only meaningful while admin mode AND global admin mode are
// both on — mask the set at read instead of clearing state in an effect. The raw
// state stays owned by useDashboardSessions.fetchBlockedDevices, which replaces
// it wholesale on every fetch (and empties it while scope is off).
const hasBlockScope = adminMode && globalAdminMode;
const blockDevice = (serialNumber: string, tenantId: string, deviceName?: string) => {
setSessionToBlock({ serialNumber, tenantId, deviceName });
@@ -85,7 +87,7 @@ export function useBlockDevice(
showBlockConfirm,
sessionToBlock,
blockingDevice,
blockedDevicesSet,
blockedDevicesSet: hasBlockScope ? blockedDevicesSet : EMPTY_SET,
setBlockedDevicesSet,
blockDevice,
confirmBlock,
@@ -7,6 +7,17 @@ import type { Session } from "../types";
const SESSIONS_PER_PAGE_KEY = "sessionsPerPage";
const DEFAULT_SESSIONS_PER_PAGE = 10;
// Deterministic serialization of the column filters (sorted keys, sorted set values)
// so structurally identical filter content always yields the same page-reset key
// regardless of insertion order.
function serializeColumnFilters(filters: Record<string, Set<string>>): string {
return JSON.stringify(
Object.keys(filters)
.sort()
.map((key) => [key, Array.from(filters[key]).sort()])
);
}
interface UseDashboardFiltersParams {
sessions: Session[];
blockedDevicesSet: Set<string>;
@@ -88,9 +99,18 @@ export function useDashboardFilters({
// Reset to page 1 whenever the displayed set or its ordering changes —
// includes external scope changes (tenant switch, global-admin toggle, tenant filter).
useEffect(() => {
// React's adjust-during-render pattern (compare the previous key, set state while
// rendering) instead of an effect, so the stale page never paints first.
const pageResetKey = JSON.stringify([
searchQuery, statusFilter, sortColumn, sortDirection,
serializeColumnFilters(columnFilters), sessionsPerPage,
globalAdminMode, tenantIdFilter, tenantId ?? null,
]);
const [prevPageResetKey, setPrevPageResetKey] = useState(pageResetKey);
if (prevPageResetKey !== pageResetKey) {
setPrevPageResetKey(pageResetKey);
setCurrentPage(1);
}, [searchQuery, statusFilter, sortColumn, sortDirection, columnFilters, sessionsPerPage, globalAdminMode, tenantIdFilter, tenantId]);
}
const handleSessionsPerPageChange = (value: number) => {
setSessionsPerPage(value);
@@ -194,45 +214,47 @@ export function useDashboardFilters({
}, [filteredSessions, sortColumn, sortDirection]);
const totalPages = Math.ceil(sortedSessions.length / sessionsPerPage);
const paginatedSessions = useMemo(() => {
const startIndex = (currentPage - 1) * sessionsPerPage;
return sortedSessions.slice(startIndex, startIndex + sessionsPerPage);
}, [sortedSessions, currentPage, sessionsPerPage]);
const handlePreviousPage = () => setCurrentPage((prev) => Math.max(1, prev - 1));
// Snap currentPage back to the last valid page when a load finished that did NOT
// grow totalPages (e.g. an active filter narrowed the new batch to zero matches).
// Without this the user would be stuck on an empty paginated slice with no way
// to recover except clicking Prev. Clamped at read (not written back via an
// effect): gated on !loadingMore so a legitimate overshoot is never reverted
// mid-fetch, and the moment the load finishes the clamp applies in the very
// same render — an effect-based snap-back could otherwise race handleNextPage
// (loadingMore still false when the effect ran) and cancel the advance.
const effectivePage =
!loadingMore && totalPages > 0 && currentPage > totalPages ? totalPages : currentPage;
const paginatedSessions = useMemo(() => {
const startIndex = (effectivePage - 1) * sessionsPerPage;
return sortedSessions.slice(startIndex, startIndex + sessionsPerPage);
}, [sortedSessions, effectivePage, sessionsPerPage]);
// Handlers step from effectivePage (the clamped value every consumer sees) so a
// raw overshoot left in state can never compound or make Prev a visual no-op.
const handlePreviousPage = () => setCurrentPage(Math.max(1, effectivePage - 1));
// When advancing past locally-loaded pages, fire loadMore() in the SAME event
// handler so React batches setLoadingMore(true) with setCurrentPage(prev+1).
// If we relied on a follow-up effect to trigger the fetch, the snap-back effect
// below would race ahead (loadingMore still false at that point) and reset
// currentPage to totalPages — leaving the user on the previous page even though
// a load was in flight, forcing a second click to actually advance.
// handler so React batches setLoadingMore(true) with setCurrentPage(...): the
// clamp above then sees loadingMore === true on the next render and lets the
// overshoot page stand while the fetch is in flight.
const handleNextPage = () => {
if (currentPage < totalPages) {
setCurrentPage((prev) => prev + 1);
if (effectivePage < totalPages) {
setCurrentPage(effectivePage + 1);
return;
}
if (hasMore && !loadingMore) {
loadMore();
setCurrentPage((prev) => prev + 1);
setCurrentPage(effectivePage + 1);
}
};
// Snap currentPage back to the last valid page once a load finishes that did NOT
// grow totalPages (e.g. an active filter narrowed the new batch to zero matches).
// Without this the user would be stuck on an empty paginated slice with no way
// to recover except clicking Prev. Gated on !loadingMore so it never fires
// mid-fetch and reverts a legitimate overshoot.
useEffect(() => {
if (loadingMore) return;
if (totalPages > 0 && currentPage > totalPages) setCurrentPage(totalPages);
}, [currentPage, totalPages, loadingMore]);
return {
searchQuery, setSearchQuery,
statusFilter, setStatusFilter,
sortColumn, sortDirection, handleSort,
columnFilters, setColumnFilters,
currentPage, sessionsPerPage, handleSessionsPerPageChange,
currentPage: effectivePage, sessionsPerPage, handleSessionsPerPageChange,
handlePreviousPage, handleNextPage,
effectiveSessions, filteredSessions, sortedSessions, paginatedSessions,
totalPages,
@@ -201,8 +201,11 @@ export function useDashboardStats({
useEffect(() => {
if (disabled) return;
if (!globalAdminMode && !tenantId) return;
setStats(null);
fetchStats();
const run = async () => {
setStats(null);
await fetchStats();
};
void run();
// fetchStats is stable; intentionally not in deps to avoid re-running on identity flips.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [tenantId, globalAdminMode, submittedTenantIdFilter, days, disabled]);
@@ -21,12 +21,12 @@ export function useTenantList(
const [tenantList, setTenantList] = useState<TenantListItem[]>([]);
useEffect(() => {
if (!globalAdminMode) {
setTenantList([]);
return;
}
let cancelled = false;
(async () => {
const run = async () => {
if (!globalAdminMode) {
setTenantList([]);
return;
}
try {
const res = await authenticatedFetch(api.config.all(), getAccessToken);
if (!res.ok || cancelled) return;
@@ -40,7 +40,8 @@ export function useTenantList(
} catch {
// Non-critical — tenant autocomplete just won't work
}
})();
};
void run();
return () => { cancelled = true; };
}, [globalAdminMode, getAccessToken]);
@@ -212,12 +212,17 @@ function HomeContent() {
// Clear the tenant filter when cross-tenant mode turns off (refetch is owned by useDashboardSessions).
// Keyed on crossTenant (not raw globalAdminMode) so a delegated ("MSP") admin — whose crossTenant is
// always on — keeps any `?tenant=` deep-link / typed filter instead of having it wiped on mount.
useEffect(() => {
// Adjust-during-render (compare-prev) instead of an effect; prev starts at null so the first
// render replicates the old effect's mount-time run, which cleared a stray `?tenant=`
// deep-link for users who are not in a cross-tenant view.
const [prevCrossTenant, setPrevCrossTenant] = useState<boolean | null>(null);
if (prevCrossTenant !== crossTenant) {
setPrevCrossTenant(crossTenant);
if (!crossTenant) {
setTenantIdFilter("");
setSubmittedTenantIdFilter("");
}
}, [crossTenant]);
}
// Auto-load more when the user needs more sessions than currently loaded
// (e.g. increased sessionsPerPage, paginated forward, or applied a sort/column filter
@@ -158,33 +158,39 @@ function DiagnosisContent() {
useEffect(() => {
if (!sessionId) return;
if (!globalAdminMode && !tenantId) return; // wait for real tenant ID
sessionIdRef.current = sessionId;
if (lastFetchedSessionId.current !== sessionId) {
hasInitialFetch.current = false;
lastFetchedSessionId.current = sessionId;
sessionFetchDone.current = false;
analysisFetchDone.current = false;
setSessionTenantId(null);
setLoading(true);
}
if (hasInitialFetch.current) return;
hasInitialFetch.current = true;
const run = async () => {
sessionIdRef.current = sessionId;
if (lastFetchedSessionId.current !== sessionId) {
hasInitialFetch.current = false;
lastFetchedSessionId.current = sessionId;
sessionFetchDone.current = false;
analysisFetchDone.current = false;
setSessionTenantId(null);
setLoading(true);
}
if (hasInitialFetch.current) return;
hasInitialFetch.current = true;
// Performance: eager-set sessionTenantId from TenantContext when known, so
// the events/analysis effect fires in parallel with fetchSessionDetails
// instead of waiting for its roundtrip — eliminates the fetch waterfall.
// Global Admins in all-tenant view fall through with null (backend resolves
// the tenant from the session itself).
if (!globalAdminMode && isGuid(tenantId)) {
setSessionTenantId(tenantId);
}
// Performance: eager-set sessionTenantId from TenantContext when known, so
// the events/analysis effect fires in parallel with fetchSessionDetails
// instead of waiting for its roundtrip — eliminates the fetch waterfall.
// Global Admins in all-tenant view fall through with null (backend resolves
// the tenant from the session itself).
if (!globalAdminMode && isGuid(tenantId)) {
setSessionTenantId(tenantId);
}
fetchSessionDetails();
await fetchSessionDetails();
};
void run();
}, [sessionId, tenantId, globalAdminMode, fetchSessionDetails]);
useEffect(() => {
if (sessionTenantId && sessionId) {
Promise.all([fetchEvents(), fetchAnalysisResults()]);
const run = async () => {
await Promise.all([fetchEvents(), fetchAnalysisResults()]);
};
void run();
}
}, [sessionTenantId, sessionId, fetchEvents, fetchAnalysisResults]);
@@ -210,8 +210,11 @@ export function useFleetHealth({
// previous-scope numbers don't linger if the refetch errors out.
useEffect(() => {
if (!scopeInitialized) return;
setData(null);
fetchData();
const run = async () => {
setData(null);
await fetchData();
};
void run();
// fetchData is stable; scopeKey/days drive the refetch.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [scopeInitialized, scopeKey, days]);

Some files were not shown because too many files have changed in this diff Show More