backend: lifecycle completeness nets + session-scope point-read + server-side dashboard search (audit P5/P6)

P5.1 Table lifecycle buckets: TableLifecycleBucketTests classifies every
TableNames constant (offboarding buckets via reflection, per-session set from a
real DeletionManifestBuilder run, reviewed KeptByDesign list). Fixes found by
the audit: UsageMetrics offboarding wipe matched 0 rows (PK=date/RK=tenantId)
-> new SafeWipe Variant D (RowKey-anchored, verify protects the "global" row);
PlatformStats tenant partition + ScriptNameCache never wiped -> tenant-PK bucket.

P5.2 SessionsIndex field manifest: SessionIndexFieldManifest (32 always / 18
conditional / 4 primary-only) pinned bidirectionally against
BuildSessionIndexEntity and MapToSessionSummary; full rebuilds now
TableUpdateMode.Replace (heals stale conditional columns); merge sites warn on
non-manifest keys; force-path now mirrors StalledAt write + clear heals.

P5.3/P6.1 Session scope resolution: ResolveSessionScopeAsync (RequestContext)
replaces the 16-site copy-paste fallback; ResolveSessionTenantIdAsync does a
point-read on the new SessionTenantLookup table (PK=sessionId) with legacy
index-scan fallback + self-heal, replacing the cross-partition scan; the
"Count == 0 -> rescan" heuristics are gone. Write paths gate cross-tenant
resolution on IsGlobalAdmin. RecomputeTriggerGate enforces the decided
read-only semantics API-side: reanalyze/rescan now 403 for Viewer/GlobalReader/
cross-tenant tenant roles. Lookup rows ride the deletion cascade (step 22).

P6.2 Server-side dashboard search: q= on search/sessions (+global) matching
exactly the client-side searchable fields, page-backfill loop (max 10 Azure
pages/request), q bound into the pagination fingerprint; the web dashboard's
searchAll() replaces the loadAll() full-history sweep (dedupe-merge, delegated
callers keep the legacy sweep).

P6.3 Context value memoization: TenantConfigContext + Auth/Notification/
Tenant/Theme providers (cuts the externally-triggered re-render cascade).

Verified: solution build 0W/0E; Functions.Tests 4106 (+37), DecisionCore 519,
V2.Core 2206, SummaryDialog 44; tsc/eslint clean; vitest 722 (+4). Drift
detection of both new manifest tests proven by mutation.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Oliver Kieselbach
2026-08-13 19:29:27 +02:00
parent f4d99b43f4
commit 88cd29d3b9
57 changed files with 1929 additions and 235 deletions
@@ -0,0 +1,62 @@
---
type: concept
title: Lifecycle Manifests & Session Scope Resolution
description: The enforced completeness nets added by the fragility-audit P5/P6 remediation — table lifecycle buckets, the SessionsIndex field manifest, the single session-scope resolver with its SessionTenantLookup point-read, and the server-side dashboard free-text search.
resource: backend
tags: [lifecycle, offboarding, sessionsindex, manifest, scope-resolution, search, performance]
timestamp: 2026-08-13
---
# Schema
Four mechanisms replace hand-synchronized copies with a declared source plus tests that fail on drift.
## 1. Table lifecycle buckets
Every table in `Constants.TableNames` must belong to at least one deletion lifecycle:
| Bucket | Mechanism | Source of truth |
|---|---|---|
| Tenant-offboarding wipe | `TenantOffboardingHandler` bucket arrays (`TenantPartitionTables`, `CompositePartitionTables`, `DiscriminatorTables`, `PropertyOnlyTables`, `RowKeyTables`, `ArchivedRuleTables`) + the one-off `TenantConfiguration` final delete | handler arrays (read via reflection by the test) |
| Per-session cascade | `DeletionManifestBuilder.BuildAsync` step list (incl. the Final tombstone → Sessions + SessionsIndex) | a real manifest built against an empty-inventory fake |
| Kept by design | reviewed exception list with per-table reasons | `TableLifecycleBucketTests.KeptByDesign` |
`TableLifecycleBucketTests` (Functions.Tests) asserts: constants set-equal `TableNames.All`, no table unclassified, kept-by-design disjoint from every wipe path, bucket entries are real constants, offboarding buckets disjoint except documented multi-membership (`BootstrapSessions`).
**Not covered mechanically:** whether a bucket's key shape matches the table's writer. That was the UsageMetrics bug (PK=date, RK=tenantId sat in the exact-PK bucket; the wipe matched 0 rows) — SafeWipe **Variant D** (`WipeByRowKeyAsync`, `RowKey eq '{tenantId}'`) now covers RowKey-anchored tables. Read the writer's `new TableEntity(pk, rk)` before classifying a new table.
## 2. SessionsIndex field manifest
`SessionIndexFieldManifest` (Functions/Services) declares the full-mirror column set: `AlwaysProjected` (32, written with defaults on every rebuild), `ConditionallyProjected` (18, written only when present), `PrimaryOnly` (4 — ServerActions + deletion-CAS fields that never touch the index). `SessionIndexFieldManifestTests` pins `BuildSessionIndexEntity` against it bidirectionally (full-row fixture built from a test-local sample table, NOT from the manifest — deriving the fixture from the manifest would make the check circular), pins that every manifest field influences `MapToSessionSummary`, and pins the rebuild's `TableUpdateMode.Replace` (a merge-mode upsert could never clear a conditional column the primary blanked). `MergeSessionIndexAsync` logs a warning for merged keys outside the manifest (fail-soft drift guard).
## 3. Session scope resolution (one implementation)
`RequestContextExtensions.ResolveSessionScopeAsync(ctx, sessionRepo, sessionId, requireGlobalAdmin)` is the single implementation of the former 15-site copy-paste fallback ("which tenant owns this session?"):
- Non-global callers always keep the middleware-validated `TargetTenantId`.
- Global-scope callers resolve via `ISessionRepository.ResolveSessionTenantIdAsync` — a point-read on **SessionTenantLookup** (PK=sessionId, RK="tenant") with a legacy cross-partition SessionsIndex scan fallback that self-heals the lookup row, so the scan is paid at most once per pre-table session.
- `requireGlobalAdmin: true` on WRITE paths gates the cross-tenant reach on `IsGlobalAdmin` so a read-only Global Reader can never steer a write into a foreign tenant.
- Unknown session → `TargetTenantId`, leaving each endpoint's own not-found semantics (404 / empty 200 / 400) in charge.
- Delegated (MSP) callers deliberately get NO fallback — extending it to `AllowedTenantIds` is a pending user decision.
The lookup row is written at session registration (fail-soft), deleted by the cascade one step before the tombstone, and rebuildable from Sessions (not in the critical-backup set). The former lazy triggers (`events.Count == 0` → scan) are gone: empty-but-owned results no longer pay a table scan, and endpoints resolve upfront.
Related enforcement added with the migration: `RecomputeTriggerGate` — `?reanalyze=true` and `?rescan=true` on the MemberRead session routes are actions (delete + rewrite stored results) and now 403 for Viewer / Global Reader / cross-tenant tenant roles (Global Admin or own-tenant Admin/Operator only).
## 4. Server-side dashboard search
`q=` on `/api/search/sessions` + `/api/global/search/sessions`: case-insensitive substring over exactly the fields the dashboard's client-side filter searches (device name, serial, manufacturer, model, status, sessionId, geo, agent version, OS fields — NOT the client-derived date/duration/blocked tokens; a server-only field would produce ghost results the client filters back out). Azure Tables has no substring operator, so the scan path backfills whole Azure pages until it has `pageSize` matches (max 10 pages per request, continuation at a page boundary — gap-free). `q` is bound into the pagination fingerprint. The web dashboard's `searchAll()` (useDashboardSessions) follows nextLink up to 10 requests / 50 matches and dedupe-merges results into the loaded list (`mergeSessionsById`), replacing the former `loadAll()` walk that downloaded the entire session history to search it client-side. Delegated callers in cross-tenant mode keep the legacy full sweep (the global search route has no delegated tier).
# Examples
Adding a new table: classify it in one lifecycle path before `TableLifecycleBucketTests` lets it ship — offboarding bucket (check the writer's PK/RK shape first), deletion-manifest step, or `KeptByDesign` with a reason.
Adding a mirrored SessionsIndex field: manifest entry + `BuildSessionIndexEntity` write + `MapToSessionSummary` read + a sample value in the test — any missing step is red.
# Citations
- `src/Backend/AutopilotMonitor.Functions.Tests/TableLifecycleBucketTests.cs`
- `src/Backend/AutopilotMonitor.Functions/Services/SessionIndexFieldManifest.cs`
- `src/Backend/AutopilotMonitor.Functions/Helpers/RequestContext.cs` (`ResolveSessionScopeAsync`)
- `src/Backend/AutopilotMonitor.Functions/Services/Offboarding/SafeWipeService.cs` (Variant D)
- `src/Backend/AutopilotMonitor.Functions/Services/TableStorageService.AgentApi.cs` (`MatchesFreeText`, backfill loop)
+1
View File
@@ -44,6 +44,7 @@ https://docs.autopilotmonitor.com (separate repository).
* [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.
* [Lifecycle Manifests & Session Scope Resolution](backend/lifecycle-manifests-and-session-scope.md) - The fragility-audit P5/P6 completeness nets: every table classified into a deletion lifecycle (offboarding buckets incl. the RowKey-anchored SafeWipe Variant D, per-session cascade, kept-by-design), the SessionsIndex field manifest with bidirectional builder/reader pinning, the single ResolveSessionScopeAsync helper backed by the SessionTenantLookup point-read, and the server-side dashboard free-text search (q=) with its page-backfill loop.
# MCP Server
+3 -1
View File
@@ -1,6 +1,8 @@
# Log
## 2026-08-13 (later)
## 2026-08-13 (evening)
* **Creation**: Added `backend/lifecycle-manifests-and-session-scope.md` — the fragility-audit P5/P6 completeness nets: (1) `TableLifecycleBucketTests` classifies every `TableNames` constant into offboarding-wipe / per-session-cascade / kept-by-design (fixed en route: UsageMetrics wiped via the new RowKey-anchored SafeWipe Variant D — the exact-PK wipe matched 0 rows; PlatformStats tenant partition + ScriptNameCache added to the tenant-PK bucket); (2) `SessionIndexFieldManifest` as the declared SessionsIndex column set with bidirectional builder/reader test pinning, Replace-mode full rebuilds, and a fail-soft merge-key guard; (3) `ResolveSessionScopeAsync` as the single session-scope fallback (16 former copies), backed by the new `SessionTenantLookup` point-read table (scan fallback + self-heal, cascade step before the tombstone), plus the `RecomputeTriggerGate` 403 for reanalyze/rescan by read-only roles; (4) server-side dashboard free-text search (`q=`) with page-backfill loop and fingerprint binding, replacing the client's full-history loadAll sweep.
* **Creation**: Added `backend/app-version-regression-radar.md` — app-version duration regression radar: per (tenant, app) the newest version's median measured install duration (nearest-rank; population Succeeded + non-skip + `HasMeasuredDuration` + no AppId collision + non-empty AppVersion) is compared against the previous version (first-seen ordering, never string sort); fires at median lift ≥2× AND ≥300s absolute with ≥10 measured installs on both sides. Episodes live as `appversionregression|{app}|{version}` tracker rows (one bell `app_version_duration_regression` + one `AppVersionDurationRegression` ops event per episode, refresh keeps `FirstNotifiedAt`, re-arm on drain/median-recovery/30d retention). Surfaces: `versionRegressions[]` + per-version `measuredInstalls`/`medianDurationSeconds`/`p95DurationSeconds` in the apps analytics responses, amber banner + duration-by-version chart on the apps detail page. Kill switch `AppVersionRegressionRadarDisabled`.
@@ -75,9 +75,9 @@ public class DeletionManifestBuilderTests
Assert.Equal(2, c["classifierVerdictsByIdLevel"]);
Assert.Equal(11, c["signalsByKind"]);
// 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);
// 20 cascade tables (incl. SessionTimeBreakdowns F1 PR2, 3 SessionAnnotations lanes +
// SessionTenantLookup P6.1) + tombstone = 21 steps; no inventory steps when no contributions row.
Assert.Equal(21, manifest.Steps.Count);
Assert.Equal(DeletionStepClass.Final, manifest.Steps.Last().Class);
}
@@ -105,7 +105,8 @@ public class DeletionManifestBuilderTests
.WithClassifierVerdictsByIdLevel(1, propValue: "classifier_marker")
.WithSignalsByKind(1, propValue: "signalkind_marker")
.WithSessionTimeBreakdown(propValue: "breakdown_marker")
.WithSessionAnnotations(propValue: "annotation_marker"));
.WithSessionAnnotations(propValue: "annotation_marker")
.WithSessionTenantLookup(propValue: "lookup_marker"));
var manifest = await NewBuilder(reader).BuildAsync(
TenantId, SessionId, "admin_delete",
@@ -148,9 +149,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 19 cascade-table steps
// (16 + the 3 SessionAnnotations lane steps).
Assert.Equal(19, nonTombstoneSteps.Count);
// Without a contributions row, neither inventory step is emitted → exactly 20 cascade-table steps
// (16 + the 3 SessionAnnotations lane steps + SessionTenantLookup).
Assert.Equal(20, nonTombstoneSteps.Count);
foreach (var step in nonTombstoneSteps)
{
Assert.Equal(0, step.RowCount);
@@ -179,8 +180,8 @@ public class DeletionManifestBuilderTests
new DeletionActor { Type = "admin", Actor = "alice@example.com" },
new DeletionRetentionContext { TenantRetentionDays = 90 });
// Expect: 19 cascade + 2 inventory + 1 tombstone = 22 steps.
Assert.Equal(22, manifest.Steps.Count);
// Expect: 20 cascade + 2 inventory + 1 tombstone = 23 steps.
Assert.Equal(23, manifest.Steps.Count);
var aggregate = manifest.Steps.Single(s => s.Class == DeletionStepClass.Aggregate);
Assert.Equal(DeletionStepNames.SoftwareInventoryDecrement, aggregate.Step);
@@ -646,6 +647,7 @@ public class DeletionManifestBuilderTests
private TableEntity? _eventSessionIndex;
private TableEntity? _sessionTimeBreakdown;
private TableEntity? _sessionInventoryContributions;
private TableEntity? _sessionTenantLookup;
private readonly Dictionary<string, TableEntity> _sessionAnnotations = new(StringComparer.Ordinal);
private string _sessionsIndexRowKey = "INDEX_" + SessionId;
@@ -801,6 +803,17 @@ public class DeletionManifestBuilderTests
return this;
}
public ReaderSeed WithSessionTenantLookup(string propValue = "lookup_marker")
{
_sessionTenantLookup = new TableEntity(SessionId, "tenant")
{
["TenantId"] = TenantId,
["Marker"] = propValue,
};
_sessionTenantLookup.ETag = new ETag("0xLOOKUP");
return this;
}
public ISessionDeletionInventoryReader Build()
{
var reader = new Mock<ISessionDeletionInventoryReader>();
@@ -833,6 +846,8 @@ public class DeletionManifestBuilderTests
.ReturnsAsync(_sessionTimeBreakdown);
reader.Setup(r => r.GetEntityOrNullAsync(Constants.TableNames.SessionInventoryContributions, TenantId, SessionId, It.IsAny<CancellationToken>()))
.ReturnsAsync(_sessionInventoryContributions);
reader.Setup(r => r.GetEntityOrNullAsync(Constants.TableNames.SessionTenantLookup, SessionId, "tenant", It.IsAny<CancellationToken>()))
.ReturnsAsync(_sessionTenantLookup);
foreach (var (lane, row) in _sessionAnnotations)
{
reader.Setup(r => r.GetEntityOrNullAsync(Constants.TableNames.SessionAnnotations, TenantId, $"{SessionId}_{lane}", It.IsAny<CancellationToken>()))
@@ -640,6 +640,12 @@ public class TenantOffboardingHandlerDrainContractTests
return Task.FromResult(0);
}
public override Task<int> WipeByRowKeyAsync(string tableName, string normalizedTenantId, CancellationToken ct = default)
{
WipeCallCount++;
return Task.FromResult(0);
}
public override Task<int> WipeBlobsByTenantPrefixAsync(string containerName, string normalizedTenantId, CancellationToken ct = default)
{
WipeCallCount++;
@@ -293,6 +293,33 @@ public class TenantOffboardingHandlerOrderingTests
Assert.Contains(Constants.TableNames.ConfigurationBackups, harness.SafeWipeProbe.ExactPartitionWipes);
}
[Fact]
public async Task PostDrain_RowKeyWipe_CoversUsageMetrics()
{
// UsageMetrics rows are PK=date, RK=tenantId — the former exact-PK wipe matched 0
// rows, so per-tenant snapshots survived offboarding. The RowKey-anchored Variant D
// wipe is their only tenant-lifecycle deletion.
var harness = Harness.New();
await harness.Sut.HandleAsync(harness.Envelope());
Assert.Contains(Constants.TableNames.UsageMetrics, harness.SafeWipeProbe.RowKeyWipes);
Assert.DoesNotContain(Constants.TableNames.UsageMetrics, harness.SafeWipeProbe.ExactPartitionWipes);
}
[Fact]
public async Task PostDrain_ExactPartitionWipe_CoversTenantStatsAndScriptNameCache()
{
// PlatformStats carries per-tenant counter rows (PK=tenantId, RK="current") and
// ScriptNameCache is keyed PK=tenantId — both previously survived offboarding.
var harness = Harness.New();
await harness.Sut.HandleAsync(harness.Envelope());
Assert.Contains(Constants.TableNames.PlatformStats, harness.SafeWipeProbe.ExactPartitionWipes);
Assert.Contains(Constants.TableNames.ScriptNameCache, harness.SafeWipeProbe.ExactPartitionWipes);
}
// ── Harness (copied minimal — only what these tests need) ───────────────────
private sealed class Harness
@@ -442,6 +469,8 @@ public class TenantOffboardingHandlerOrderingTests
public List<string> PropertyOnlyWipes { get; } = new();
/// <summary>Table names passed to the exact-partition (Variant A) wipe — lets tests assert coverage.</summary>
public List<string> ExactPartitionWipes { get; } = new();
/// <summary>Table names passed to the RowKey-anchored (Variant D) wipe — lets tests assert coverage.</summary>
public List<string> RowKeyWipes { get; } = new();
public CountingSafeWipeService() : base(
new TableStorageService(Mock.Of<TableServiceClient>(), NullLogger<TableStorageService>.Instance),
new BlobStorageService(new BlobServiceClient("UseDevelopmentStorage=true"),
@@ -451,6 +480,7 @@ public class TenantOffboardingHandlerOrderingTests
public override Task<int> WipeByCompositePartitionRangeAsync(string t, string i, CancellationToken c = default) { WipeCallCount++; return Task.FromResult(0); }
public override Task<int> WipeByDiscriminatorAndTenantPropertyAsync(string t, string d, string i, CancellationToken c = default) { WipeCallCount++; return Task.FromResult(0); }
public override Task<int> WipeByTenantIdPropertyAsync(string t, string i, CancellationToken c = default) { WipeCallCount++; PropertyOnlyWipes.Add(t); return Task.FromResult(0); }
public override Task<int> WipeByRowKeyAsync(string t, string i, CancellationToken c = default) { WipeCallCount++; RowKeyWipes.Add(t); return Task.FromResult(0); }
public override Task<int> WipeBlobsByTenantPrefixAsync(string c, string i, CancellationToken ct = default) { WipeCallCount++; return Task.FromResult(0); }
}
@@ -55,6 +55,14 @@ public class OffboardingFiltersTests
Assert.Throws<ArgumentException>(() => OffboardingFilters.TenantIdProperty(input));
}
[Theory]
[InlineData("not-a-guid")]
[InlineData("")]
public void RowKeyEquals_ThrowsForNonGuid(string input)
{
Assert.Throws<ArgumentException>(() => OffboardingFilters.RowKeyEquals(input));
}
// ── Filter shapes ───────────────────────────────────────────────────────────
[Fact]
@@ -97,6 +105,17 @@ public class OffboardingFiltersTests
Assert.DoesNotContain("PartitionKey", filter);
}
[Fact]
public void RowKeyEquals_EmitsRowKeyOnlyFilter()
{
// Variant D anchors on the ROW key (UsageMetrics: PK=date, RK=tenantId) — the
// filter must not constrain PartitionKey, otherwise it degenerates back into the
// exact-PK wipe that matched 0 rows.
var filter = OffboardingFilters.RowKeyEquals(ValidTenant);
Assert.Equal($"RowKey eq '{ValidTenant}'", filter);
Assert.DoesNotContain("PartitionKey", filter);
}
// ── Injection neutralization (defense in depth even after EnsureValidGuid) ──
[Fact]
@@ -97,6 +97,49 @@ public class SafeWipeServiceTests
harness.Sut.WipeByTenantIdPropertyAsync("FakeTable", TenantId));
}
[Fact]
public async Task RowKey_AbortsWhenForeignRowKeyReturned()
{
// Variant D anchors on the RowKey (UsageMetrics: PK=date, RK=tenantId). The "global"
// aggregate row shares partitions with tenant rows — verify must reject anything whose
// RowKey is not exactly the tenant id.
var harness = new Harness(
queriedRows: new[]
{
new TableEntity("2026-08-01", TenantId),
new TableEntity("2026-08-01", "global"), // <-- must never be wiped
});
await Assert.ThrowsAsync<SafeWipeVerificationException>(() =>
harness.Sut.WipeByRowKeyAsync("FakeTable", TenantId));
Assert.Empty(harness.SubmittedBatches);
Assert.Empty(harness.PerRowDeletes);
}
[Fact]
public async Task RowKey_DeletesAcrossPartitionsWithETagAll()
{
// One row per date partition — batches are grouped by PK, so 3 partitions = 3 batches.
var harness = new Harness(
queriedRows: new[]
{
new TableEntity("2026-08-01", TenantId),
new TableEntity("2026-08-02", TenantId),
new TableEntity("2026-08-03", TenantId),
});
var deleted = await harness.Sut.WipeByRowKeyAsync("FakeTable", TenantId);
Assert.Equal(3, deleted);
Assert.Equal(3, harness.SubmittedBatches.Count);
foreach (var action in harness.SubmittedBatches.SelectMany(b => b))
{
Assert.Equal(TableTransactionActionType.Delete, action.ActionType);
Assert.Equal(ETag.All, action.Entity.ETag);
}
}
[Fact]
public async Task ExactPartition_DeletesEveryRowWithETagAll()
{
@@ -175,6 +218,8 @@ public class SafeWipeServiceTests
() => harness.Sut.WipeByDiscriminatorAndTenantPropertyAsync("t", "CodeLookup", "not-a-guid"));
await Assert.ThrowsAsync<ArgumentException>(
() => harness.Sut.WipeByTenantIdPropertyAsync("t", "not-a-guid"));
await Assert.ThrowsAsync<ArgumentException>(
() => harness.Sut.WipeByRowKeyAsync("t", "not-a-guid"));
}
// ── Harness ─────────────────────────────────────────────────────────────────
@@ -103,7 +103,7 @@ public class SessionAnnotationFunctionsTests
annotationRepo.Setup(r => r.GetForSessionAsync(It.IsAny<string>(), It.IsAny<string>()))
.ReturnsAsync(new List<SessionAnnotation>());
var sessionRepo = new Mock<ISessionRepository>(MockBehavior.Loose);
sessionRepo.Setup(r => r.FindSessionTenantIdAsync(SessionId)).ReturnsAsync(TenantId);
sessionRepo.Setup(r => r.ResolveSessionTenantIdAsync(SessionId)).ReturnsAsync(TenantId);
return new GetSessionAnnotationsFunction(
NullLogger<GetSessionAnnotationsFunction>.Instance,
annotationRepo.Object, sessionRepo.Object);
@@ -291,7 +291,7 @@ public class SessionAnnotationFunctionsTests
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);
sessionRepo.Setup(r => r.ResolveSessionTenantIdAsync(SessionId)).ReturnsAsync(TenantId);
var ruleRepo = new Mock<IRuleRepository>(MockBehavior.Loose);
ruleRepo.Setup(r => r.GetRuleResultsAsync(It.IsAny<string>(), It.IsAny<string>()))
@@ -102,7 +102,7 @@ public class SessionEventsPaginationTests
// Regression: previously the function called BuildNextLink with the raw
// ?tenantId= query param, which is null when GA reads /api/sessions/{id}/events
// without explicitly passing tenantId. Page 1 then bound the token to the
// resolved tenant (via FindSessionTenantIdAsync), but the nextLink omitted
// resolved tenant (via ResolveSessionTenantIdAsync), but the nextLink omitted
// it — so page 2 validated against the GA's JWT tenant and the token was
// rejected as cross_tenant. Fix: nextLink now carries the resolved tenantId
// verbatim, and the function honours ?tenantId= on follow-up pages for GA.
@@ -0,0 +1,84 @@
using System;
using AutopilotMonitor.Functions.Pagination;
using AutopilotMonitor.Functions.Services;
using AutopilotMonitor.Shared.Models;
using Xunit;
namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// Fragilitätsaudit P6.2: server-side free-text search (<c>q=</c>) for the dashboard search
/// box, replacing the client's unbounded loadAll() sweep. The predicate must mirror the web
/// dashboard's client-side searchable field set (minus derived-only tokens) — see the doc on
/// <c>TableStorageService.MatchesFreeText</c>.
/// </summary>
public class SessionFreeTextSearchTests
{
private static SessionSummary Sample() => new()
{
SessionId = "22222222-2222-2222-2222-222222222222",
TenantId = "11111111-1111-1111-1111-111111111111",
DeviceName = "DESKTOP-A1B2C3",
SerialNumber = "5CG1234XYZ",
Manufacturer = "Contoso",
Model = "EliteBook 840",
Status = SessionStatus.Failed,
GeoCountry = "DE",
GeoRegion = "Hessen",
GeoCity = "Frankfurt",
AgentVersion = "2.0.1400",
OsName = "Windows 11",
OsBuild = "26100.1000",
OsDisplayVersion = "24H2",
OsEdition = "Enterprise",
OsLanguage = "de-DE",
};
[Theory]
[InlineData("a1b2")] // device name, case-insensitive substring
[InlineData("5cg1234")] // serial
[InlineData("contoso")] // manufacturer
[InlineData("elitebook")] // model
[InlineData("failed")] // status text
[InlineData("2222-2222")] // sessionId fragment
[InlineData("frankfurt")] // geo city
[InlineData("2.0.1400")] // agent version
[InlineData("26100")] // os build
[InlineData("24h2")] // os display version
public void MatchesFreeText_FindsSubstringAcrossDashboardFields(string q)
{
Assert.True(TableStorageService.MatchesFreeText(Sample(), q));
}
[Theory]
[InlineData("tailspin")]
[InlineData("succeeded")]
[InlineData("99999")]
public void MatchesFreeText_RejectsNonMatches(string q)
{
Assert.False(TableStorageService.MatchesFreeText(Sample(), q));
}
[Fact]
public void MatchesFreeText_EmptyQuery_MatchesEverything()
{
Assert.True(TableStorageService.MatchesFreeText(Sample(), null));
Assert.True(TableStorageService.MatchesFreeText(Sample(), string.Empty));
}
[Fact]
public void Fingerprint_BindsQ_SoContinuationCannotCrossQueries()
{
var withQ = new SessionSearchFilter { Q = "desktop" };
var otherQ = new SessionSearchFilter { Q = "laptop" };
var noQ = new SessionSearchFilter();
const string caller = "11111111-1111-1111-1111-111111111111";
var fp1 = SearchSessionsPagination.Fingerprint("search:tenant", caller, null, withQ);
var fp2 = SearchSessionsPagination.Fingerprint("search:tenant", caller, null, otherQ);
var fp3 = SearchSessionsPagination.Fingerprint("search:tenant", caller, null, noQ);
Assert.NotEqual(fp1, fp2);
Assert.NotEqual(fp1, fp3);
}
}
@@ -0,0 +1,314 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.Json;
using System.Threading;
using Azure;
using Azure.Data.Tables;
using AutopilotMonitor.Functions.Services;
using AutopilotMonitor.Shared;
using Microsoft.Extensions.Logging.Abstractions;
using Moq;
using Xunit;
namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// Fragilitätsaudit P5.2: <see cref="SessionIndexFieldManifest"/> is the declared SessionsIndex
/// column set; builder, reader, and merge guard are all pinned against it here.
/// <list type="bullet">
/// <item>Builder ↔ manifest bidirectional: a field added to <c>BuildSessionIndexEntity</c> but
/// not the manifest fails, and vice versa.</item>
/// <item>Reader coverage: every manifest field must influence <c>MapToSessionSummary</c> output —
/// a projected-but-never-served column is dead weight; a served-but-not-projected one is
/// the drift bug.</item>
/// <item>Full-rebuild upsert is Replace-mode, so conditional fields the primary cleared do not
/// survive as stale index columns.</item>
/// </list>
/// </summary>
public class SessionIndexFieldManifestTests
{
private const string TenantId = "11111111-1111-1111-1111-111111111111";
private const string SessionId = "22222222-2222-2222-2222-222222222222";
private const string IndexRowKey = "2516000000000000000_" + SessionId;
private static readonly DateTime StartedAt = new(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc);
private static readonly string[] SystemKeys = { "PartitionKey", "RowKey", "Timestamp", "odata.etag" };
/// <summary>
/// One distinctly non-default sample value per manifest field. A new manifest field without
/// an entry here fails the tests with a clear message — extend this map alongside the
/// manifest. Values are typed the way the WRITER stores them (wire form).
/// </summary>
private static readonly Dictionary<string, object> SampleValues = new(StringComparer.Ordinal)
{
// SessionId sample deliberately differs from the RowKey suffix so the reader test can
// detect that the stored property (not the RowKey fallback) is being served.
["SessionId"] = "33333333-3333-3333-3333-333333333333",
["SerialNumber"] = "SN-0001",
["DeviceName"] = "PC-0001",
["Manufacturer"] = "Contoso",
["Model"] = "Elite-1",
["StartedAt"] = new DateTimeOffset(StartedAt),
["Status"] = "Failed",
["CurrentPhase"] = 3,
["CurrentPhaseDetail"] = "phase-detail-1",
["EventCount"] = 42,
["EnrollmentType"] = "v2",
["IsPreProvisioned"] = true,
["IsHybridJoin"] = true,
["IsUserDriven"] = true,
["IsSelfDeployingProfile"] = true,
["IsCloudPc"] = true,
["AgentVersion"] = "2.0.1400",
["ImeAgentVersion"] = "1.2.3.4",
["OsName"] = "Windows 11",
["OsBuild"] = "26100.1000",
["OsDisplayVersion"] = "24H2",
["OsEdition"] = "Enterprise",
["OsLanguage"] = "de-DE",
["GeoCountry"] = "DE",
["GeoRegion"] = "HE",
["GeoCity"] = "Frankfurt",
["GeoLoc"] = "50.11,8.68",
["PlatformScriptCount"] = 2,
["RemediationScriptCount"] = 3,
["RebootCount"] = 4,
["ExcessiveEventsAlerted"] = true,
["ExcessiveEventsAutoActioned"] = true,
["CompletedAt"] = new DateTimeOffset(StartedAt.AddMinutes(45)),
["FailureReason"] = "esp timeout",
["FailureSource"] = "ime",
["ReconcileReason"] = "late completion",
["EspSoftFailure"] = true,
["CompletionSource"] = "esp_exit",
["ValidatedBy"] = "AutopilotV1",
["FailureSnapshotJson"] = "{\"x\":1}",
["AdminMarkedAction"] = "failed",
["DurationSeconds"] = 360,
["DiagnosticsBlobName"] = "diag-0001.zip",
["DiagnosticsBlobDestination"] = "Hosted",
["LastEventAt"] = new DateTimeOffset(StartedAt.AddMinutes(44)),
["ResumedAt"] = new DateTimeOffset(StartedAt.AddMinutes(10)),
["StalledAt"] = new DateTimeOffset(StartedAt.AddMinutes(20)),
["AvgApiLatencyMs"] = 513.5,
["ApiRequestCount"] = 100,
["ConnectionType"] = "Ethernet",
};
// ── Manifest shape ───────────────────────────────────────────────────────────
[Fact]
public void Manifest_IsInternallyConsistent()
{
Assert.Empty(SessionIndexFieldManifest.AlwaysProjected
.Intersect(SessionIndexFieldManifest.ConditionallyProjected, StringComparer.Ordinal));
Assert.Equal(SessionIndexFieldManifest.All.Length,
SessionIndexFieldManifest.All.Distinct(StringComparer.Ordinal).Count());
Assert.Empty(SessionIndexFieldManifest.PrimaryOnly
.Intersect(SessionIndexFieldManifest.All, StringComparer.Ordinal));
}
[Fact]
public void EveryManifestField_HasASampleValueInThisTest()
{
var missing = SessionIndexFieldManifest.All
.Where(f => !SampleValues.ContainsKey(f))
.ToList();
Assert.True(missing.Count == 0,
"New manifest field(s) without a sample value in SessionIndexFieldManifestTests.SampleValues:\n - "
+ string.Join("\n - ", missing));
}
// ── Builder ↔ manifest (bidirectional) ───────────────────────────────────────
[Fact]
public void FullyPopulatedRow_BuilderOutputMatchesManifestExactly()
{
var idx = TableStorageService.BuildSessionIndexEntity(FullSessionRow(), IndexRowKey, StartedAt);
var dataKeys = DataKeys(idx);
var missingFromBuilder = SessionIndexFieldManifest.All.Except(dataKeys, StringComparer.Ordinal).ToList();
var missingFromManifest = dataKeys.Except(SessionIndexFieldManifest.All, StringComparer.Ordinal).ToList();
Assert.True(missingFromBuilder.Count == 0,
"Manifest fields BuildSessionIndexEntity did not project from a fully-populated row:\n - "
+ string.Join("\n - ", missingFromBuilder));
Assert.True(missingFromManifest.Count == 0,
"BuildSessionIndexEntity projects fields missing from SessionIndexFieldManifest:\n - "
+ string.Join("\n - ", missingFromManifest));
}
[Fact]
public void MinimalRow_BuilderOutputMatchesAlwaysProjectedExactly()
{
var minimal = new TableEntity(TenantId, SessionId)
{
["StartedAt"] = new DateTimeOffset(StartedAt),
};
var idx = TableStorageService.BuildSessionIndexEntity(minimal, IndexRowKey, StartedAt);
var dataKeys = DataKeys(idx);
var missing = SessionIndexFieldManifest.AlwaysProjected.Except(dataKeys, StringComparer.Ordinal).ToList();
var unexpected = dataKeys.Except(SessionIndexFieldManifest.AlwaysProjected, StringComparer.Ordinal).ToList();
Assert.True(missing.Count == 0,
"AlwaysProjected fields absent from a minimal-row rebuild (default missing in builder):\n - "
+ string.Join("\n - ", missing));
Assert.True(unexpected.Count == 0,
"Fields written for a minimal row although classified ConditionallyProjected (or unlisted):\n - "
+ string.Join("\n - ", unexpected));
}
// ── Reader coverage ──────────────────────────────────────────────────────────
[Fact]
public void EveryManifestField_InfluencesMappedSessionSummary()
{
var sut = NewMapperService();
var baseline = Serialize(sut.MapIndexEntityToSessionSummary(MinimalIndexEntity()));
var silent = new List<string>();
foreach (var field in SessionIndexFieldManifest.All)
{
var entity = MinimalIndexEntity();
entity[field] = SampleValues[field];
var mapped = Serialize(sut.MapIndexEntityToSessionSummary(entity));
if (mapped == baseline)
silent.Add(field);
}
Assert.True(silent.Count == 0,
"Manifest fields whose value does NOT surface in MapToSessionSummary — either the reader "
+ "ignores the column (dead projection) or the sample value needs adjusting:\n - "
+ string.Join("\n - ", silent));
}
// ── Merge guard ──────────────────────────────────────────────────────────────
[Fact]
public void FindNonManifestKeys_FlagsOnlyForeignDataKeys()
{
var merge = new TableEntity(TenantId, IndexRowKey)
{
["Status"] = "Failed",
["RebootCount"] = 2,
["BrandNewField"] = "x",
};
var offenders = SessionIndexFieldManifest.FindNonManifestKeys(merge);
Assert.Equal(new[] { "BrandNewField" }, offenders);
}
[Fact]
public void FindNonManifestKeys_EmptyForPureManifestMerge()
{
var merge = new TableEntity(TenantId, IndexRowKey)
{
["EventCount"] = 7,
["LastEventAt"] = new DateTimeOffset(StartedAt),
};
Assert.Empty(SessionIndexFieldManifest.FindNonManifestKeys(merge));
}
// ── Full-rebuild upsert semantics ────────────────────────────────────────────
[Fact]
public async Task UpsertSessionIndex_UsesReplaceMode()
{
// Replace (not merge) is what heals stale conditional columns after the primary
// cleared them — merge-mode upsert cannot remove a column.
var harness = new UpsertHarness();
await harness.Sut.UpsertSessionIndexAsync(FullSessionRow(), StartedAt);
harness.Index.Verify(t => t.UpsertEntityAsync(
It.Is<TableEntity>(e => e.RowKey.EndsWith("_" + SessionId, StringComparison.Ordinal)),
TableUpdateMode.Replace,
It.IsAny<CancellationToken>()),
Times.Once);
// IndexRowKey written back onto the Sessions row (merge).
harness.Sessions.Verify(t => t.UpdateEntityAsync(
It.Is<ITableEntity>(e => e.PartitionKey == TenantId && e.RowKey == SessionId),
It.IsAny<ETag>(),
TableUpdateMode.Merge,
It.IsAny<CancellationToken>()),
Times.Once);
}
[Fact]
public async Task UpsertSessionIndex_DeletesStaleRowKey_WhenStartedAtShifted()
{
var staleRowKey = "9999999999999999999_" + SessionId;
var row = FullSessionRow();
row["IndexRowKey"] = staleRowKey;
var harness = new UpsertHarness();
await harness.Sut.UpsertSessionIndexAsync(row, StartedAt);
harness.Index.Verify(t => t.DeleteEntityAsync(
TenantId, staleRowKey, It.IsAny<ETag>(), It.IsAny<CancellationToken>()),
Times.Once);
}
// ── Helpers ──────────────────────────────────────────────────────────────────
/// <summary>
/// A Sessions row carrying every known field. Deliberately built from SampleValues (the
/// test-local list), NOT from the manifest — deriving the input from the manifest would
/// make the bidirectional check circular: a field dropped from the manifest would also
/// vanish from the input row and the drift would pass unnoticed.
/// </summary>
private static TableEntity FullSessionRow()
{
var row = new TableEntity(TenantId, SessionId);
foreach (var (field, value) in SampleValues)
{
// SessionId comes from the RowKey; the builder never reads a SessionId property.
if (field == "SessionId")
continue;
row[field] = value;
}
return row;
}
private static TableEntity MinimalIndexEntity() => new(TenantId, IndexRowKey);
private static List<string> DataKeys(TableEntity entity)
=> entity.Keys.Except(SystemKeys, StringComparer.Ordinal).ToList();
private static string Serialize(object o) => JsonSerializer.Serialize(o);
private static TableStorageService NewMapperService()
=> new(Mock.Of<TableServiceClient>(), NullLogger<TableStorageService>.Instance);
private sealed class UpsertHarness
{
public Mock<TableClient> Sessions { get; } = new();
public Mock<TableClient> Index { get; } = new();
public TableStorageService Sut { get; }
public UpsertHarness()
{
Index
.Setup(t => t.UpsertEntityAsync(It.IsAny<TableEntity>(), It.IsAny<TableUpdateMode>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(Mock.Of<Response>());
Index
.Setup(t => t.DeleteEntityAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<ETag>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(Mock.Of<Response>());
Sessions
.Setup(t => t.UpdateEntityAsync(It.IsAny<ITableEntity>(), It.IsAny<ETag>(), It.IsAny<TableUpdateMode>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(Mock.Of<Response>());
var serviceClient = new Mock<TableServiceClient>();
serviceClient.Setup(s => s.GetTableClient(Constants.TableNames.Sessions)).Returns(Sessions.Object);
serviceClient.Setup(s => s.GetTableClient(Constants.TableNames.SessionsIndex)).Returns(Index.Object);
Sut = new TableStorageService(serviceClient.Object, NullLogger<TableStorageService>.Instance);
}
}
}
@@ -0,0 +1,264 @@
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using Azure;
using Azure.Data.Tables;
using AutopilotMonitor.Functions.Helpers;
using AutopilotMonitor.Functions.Services;
using AutopilotMonitor.Shared;
using AutopilotMonitor.Shared.DataAccess;
using Microsoft.Extensions.Logging.Abstractions;
using Moq;
using Xunit;
using RequestContext = AutopilotMonitor.Functions.Helpers.RequestContext;
namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// Fragilitätsaudit P5.3/P6.1: <c>RequestContextExtensions.ResolveSessionScopeAsync</c> is the
/// single implementation of the former 15-site copy-paste session-scope fallback, and
/// <c>TableStorageService.ResolveSessionTenantIdAsync</c> replaces the cross-partition
/// SessionsIndex scan with a SessionTenantLookup point-read (legacy scan fallback + self-heal).
/// These tests pin the semantics every migrated endpoint now inherits.
/// </summary>
public class SessionScopeResolutionTests
{
private const string HomeTenant = "11111111-1111-1111-1111-111111111111";
private const string OtherTenant = "44444444-4444-4444-4444-444444444444";
private const string SessionId = "22222222-2222-2222-2222-222222222222";
// ── RequestContextExtensions.ResolveSessionScopeAsync ───────────────────────
[Fact]
public async Task NonGlobalCaller_KeepsTargetTenant_WithoutTouchingTheRepo()
{
var repo = new Mock<ISessionRepository>(MockBehavior.Strict);
var ctx = new RequestContext { TenantId = HomeTenant, TargetTenantId = HomeTenant, UserRole = Constants.TenantRoles.Admin };
var result = await ctx.ResolveSessionScopeAsync(repo.Object, SessionId);
Assert.Equal(HomeTenant, result);
repo.VerifyNoOtherCalls();
}
[Fact]
public async Task GlobalScope_CrossTenantSession_ResolvesToOwningTenant()
{
var repo = new Mock<ISessionRepository>();
repo.Setup(r => r.ResolveSessionTenantIdAsync(SessionId)).ReturnsAsync(OtherTenant);
var ctx = new RequestContext { TenantId = HomeTenant, TargetTenantId = HomeTenant, IsGlobalReader = true };
var result = await ctx.ResolveSessionScopeAsync(repo.Object, SessionId);
Assert.Equal(OtherTenant, result);
}
[Fact]
public async Task GlobalScope_OwnTenantSession_KeepsTargetTenant()
{
// Case-insensitive comparison: a resolver returning a different casing must not
// count as "different tenant".
var repo = new Mock<ISessionRepository>();
repo.Setup(r => r.ResolveSessionTenantIdAsync(SessionId)).ReturnsAsync(HomeTenant.ToUpperInvariant());
var ctx = new RequestContext { TenantId = HomeTenant, TargetTenantId = HomeTenant, IsGlobalAdmin = true };
var result = await ctx.ResolveSessionScopeAsync(repo.Object, SessionId);
Assert.Equal(HomeTenant, result);
}
[Fact]
public async Task GlobalScope_UnknownSession_KeepsTargetTenant_SoEndpointNotFoundHandlingStaysInCharge()
{
var repo = new Mock<ISessionRepository>();
repo.Setup(r => r.ResolveSessionTenantIdAsync(SessionId)).ReturnsAsync((string?)null);
var ctx = new RequestContext { TenantId = HomeTenant, TargetTenantId = HomeTenant, IsGlobalAdmin = true };
var result = await ctx.ResolveSessionScopeAsync(repo.Object, SessionId);
Assert.Equal(HomeTenant, result);
}
[Fact]
public async Task RequireGlobalAdmin_ExcludesGlobalReader_FromCrossTenantWrites()
{
// Write paths (annotation upsert) gate the resolve on IsGlobalAdmin: a read-only
// Global Reader keeps the middleware-validated tenant and can never steer a write
// into a foreign tenant.
var repo = new Mock<ISessionRepository>(MockBehavior.Strict);
var ctx = new RequestContext { TenantId = HomeTenant, TargetTenantId = HomeTenant, IsGlobalReader = true };
var result = await ctx.ResolveSessionScopeAsync(repo.Object, SessionId, requireGlobalAdmin: true);
Assert.Equal(HomeTenant, result);
repo.VerifyNoOtherCalls();
}
[Fact]
public async Task RequireGlobalAdmin_StillResolves_ForGlobalAdmin()
{
var repo = new Mock<ISessionRepository>();
repo.Setup(r => r.ResolveSessionTenantIdAsync(SessionId)).ReturnsAsync(OtherTenant);
var ctx = new RequestContext { TenantId = HomeTenant, TargetTenantId = HomeTenant, IsGlobalAdmin = true };
var result = await ctx.ResolveSessionScopeAsync(repo.Object, SessionId, requireGlobalAdmin: true);
Assert.Equal(OtherTenant, result);
}
[Fact]
public async Task DelegatedCaller_GetsNoFallback_ByDesign()
{
// Deliberate: delegated (MSP) callers are NOT global scope — extending the fallback
// to AllowedTenantIds would widen the authz surface and is a separate user decision.
var repo = new Mock<ISessionRepository>(MockBehavior.Strict);
var ctx = new RequestContext
{
TenantId = HomeTenant,
TargetTenantId = OtherTenant,
IsDelegatedReader = true,
AllowedTenantIds = new[] { OtherTenant },
};
var result = await ctx.ResolveSessionScopeAsync(repo.Object, SessionId);
Assert.Equal(OtherTenant, result);
repo.VerifyNoOtherCalls();
}
// ── TableStorageService.ResolveSessionTenantIdAsync ─────────────────────────
[Fact]
public async Task PointReadHit_ReturnsTenant_WithoutScanningTheIndex()
{
var harness = new ResolveHarness(lookupRow: LookupRow(OtherTenant));
var result = await harness.Sut.ResolveSessionTenantIdAsync(SessionId);
Assert.Equal(OtherTenant, result);
harness.Index.Verify(t => t.QueryAsync<TableEntity>(
It.IsAny<string>(), It.IsAny<int?>(), It.IsAny<IEnumerable<string>>(), It.IsAny<CancellationToken>()),
Times.Never);
}
[Fact]
public async Task PointReadMiss_FallsBackToScan_AndHealsTheLookupRow()
{
var indexRow = new TableEntity(OtherTenant, "0000000000000000001_" + SessionId) { ["SessionId"] = SessionId };
var harness = new ResolveHarness(lookupRow: null, indexRows: new[] { indexRow });
var result = await harness.Sut.ResolveSessionTenantIdAsync(SessionId);
Assert.Equal(OtherTenant, result);
// Self-heal: the lookup row is written so the next resolve is a point-read.
harness.Lookup.Verify(t => t.UpsertEntityAsync(
It.Is<TableEntity>(e => e.PartitionKey == SessionId
&& e.RowKey == "tenant"
&& e.GetString("TenantId") == OtherTenant),
It.IsAny<TableUpdateMode>(),
It.IsAny<CancellationToken>()),
Times.Once);
}
[Fact]
public async Task UnknownSession_ReturnsNull_AndWritesNoHealRow()
{
var harness = new ResolveHarness(lookupRow: null, indexRows: Array.Empty<TableEntity>());
var result = await harness.Sut.ResolveSessionTenantIdAsync(SessionId);
Assert.Null(result);
harness.Lookup.Verify(t => t.UpsertEntityAsync(
It.IsAny<TableEntity>(), It.IsAny<TableUpdateMode>(), It.IsAny<CancellationToken>()),
Times.Never);
}
[Fact]
public async Task InvalidSessionId_Throws_BeforeAnyStorageCall()
{
var harness = new ResolveHarness(lookupRow: null);
await Assert.ThrowsAsync<ArgumentException>(
() => harness.Sut.ResolveSessionTenantIdAsync("not-a-guid"));
}
// ── RecomputeTriggerGate ─────────────────────────────────────────────────────
[Theory]
[InlineData("Admin", true)]
[InlineData("Operator", true)]
[InlineData("Viewer", false)]
[InlineData("", false)]
public void OwnTenantRoles_GateRecompute(string role, bool expected)
{
var ctx = new RequestContext { TenantId = HomeTenant, TargetTenantId = HomeTenant, UserRole = role };
Assert.Equal(expected, RecomputeTriggerGate.CanTriggerRecompute(ctx, HomeTenant));
}
[Fact]
public void GlobalAdmin_MayRecompute_CrossTenant()
{
var ctx = new RequestContext { TenantId = HomeTenant, IsGlobalAdmin = true };
Assert.True(RecomputeTriggerGate.CanTriggerRecompute(ctx, OtherTenant));
}
[Fact]
public void GlobalReader_MayNotRecompute_Anywhere()
{
var ctx = new RequestContext { TenantId = HomeTenant, IsGlobalReader = true };
Assert.False(RecomputeTriggerGate.CanTriggerRecompute(ctx, HomeTenant));
Assert.False(RecomputeTriggerGate.CanTriggerRecompute(ctx, OtherTenant));
}
[Fact]
public void AdminRole_DoesNotCarryCrossTenant()
{
// A tenant Admin viewing a FOREIGN tenant's session (delegated read) must not be able
// to trigger recomputes there — the role binds to the caller's own tenant.
var ctx = new RequestContext { TenantId = HomeTenant, UserRole = Constants.TenantRoles.Admin };
Assert.False(RecomputeTriggerGate.CanTriggerRecompute(ctx, OtherTenant));
}
// ── Harness ──────────────────────────────────────────────────────────────────
private static TableEntity LookupRow(string tenantId)
=> new(SessionId, "tenant") { ["TenantId"] = tenantId };
private sealed class ResolveHarness
{
public Mock<TableClient> Lookup { get; } = new();
public Mock<TableClient> Index { get; } = new();
public TableStorageService Sut { get; }
public ResolveHarness(TableEntity? lookupRow, TableEntity[]? indexRows = null)
{
if (lookupRow != null)
{
Lookup
.Setup(t => t.GetEntityAsync<TableEntity>(SessionId, "tenant", It.IsAny<IEnumerable<string>>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(Response.FromValue(lookupRow, Mock.Of<Response>()));
}
else
{
Lookup
.Setup(t => t.GetEntityAsync<TableEntity>(SessionId, "tenant", It.IsAny<IEnumerable<string>>(), It.IsAny<CancellationToken>()))
.ThrowsAsync(new RequestFailedException(404, "Not Found"));
}
Lookup
.Setup(t => t.UpsertEntityAsync(It.IsAny<TableEntity>(), It.IsAny<TableUpdateMode>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(Mock.Of<Response>());
var page = Page<TableEntity>.FromValues(indexRows ?? Array.Empty<TableEntity>(), null, Mock.Of<Response>());
Index
.Setup(t => t.QueryAsync<TableEntity>(
It.IsAny<string>(), It.IsAny<int?>(), It.IsAny<IEnumerable<string>>(), It.IsAny<CancellationToken>()))
.Returns(AsyncPageable<TableEntity>.FromPages(new[] { page }));
var serviceClient = new Mock<TableServiceClient>();
serviceClient.Setup(s => s.GetTableClient(Constants.TableNames.SessionTenantLookup)).Returns(Lookup.Object);
serviceClient.Setup(s => s.GetTableClient(Constants.TableNames.SessionsIndex)).Returns(Index.Object);
Sut = new TableStorageService(serviceClient.Object, NullLogger<TableStorageService>.Instance);
}
}
}
@@ -0,0 +1,274 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Reflection;
using System.Runtime.CompilerServices;
using System.Threading;
using System.Threading.Tasks;
using Azure.Data.Tables;
using AutopilotMonitor.Functions.Services.Deletion;
using AutopilotMonitor.Functions.Services.Offboarding;
using AutopilotMonitor.Shared;
using AutopilotMonitor.Shared.Models.Deletion;
using Microsoft.Extensions.Logging.Abstractions;
using Xunit;
namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// Lifecycle-completeness net for <see cref="Constants.TableNames"/> (Fragilitätsaudit P5.1).
/// Every table must be covered by at least one deletion lifecycle — tenant-offboarding wipe
/// (read via reflection from <see cref="TenantOffboardingHandler"/>'s bucket arrays), the
/// per-session deletion cascade (derived from a real <see cref="DeletionManifestBuilder"/>
/// manifest), or the explicit <see cref="KeptByDesign"/> list below. A new table that lands
/// in no bucket fails here with instructions; a kept-by-design table that someone starts
/// wiping (or vice versa) fails too.
///
/// NOT covered mechanically: whether a bucket's KEY SHAPE matches the table's writer
/// (the UsageMetrics bug this package fixed — PK=date/RK=tenantId sat in the exact-PK
/// bucket and the wipe matched 0 rows). When classifying a new table, read its writer's
/// <c>new TableEntity(pk, rk)</c> call before picking the bucket.
/// </summary>
public class TableLifecycleBucketTests
{
private const string TenantId = "11111111-1111-1111-1111-111111111111";
private const string SessionId = "22222222-2222-2222-2222-222222222222";
/// <summary>
/// Tables that intentionally survive both tenant offboarding and session deletion.
/// Each entry needs a reason — this list is the reviewed exception set, not a dumping
/// ground for "forgot to classify".
/// </summary>
private static readonly Dictionary<string, string> KeptByDesign = new(StringComparer.Ordinal)
{
[Constants.TableNames.AdminConfiguration] = "single platform 'GlobalConfig' partition, no tenant rows",
[Constants.TableNames.GlobalAdmins] = "platform operator identities",
[Constants.TableNames.McpUsers] = "platform MCP identities (PK='McpUsers', RK=UPN)",
[Constants.TableNames.TenantGroupAssignments] = "per-UPN rows without tenant anchor; offboarding never touches it (see Constants doc)",
[Constants.TableNames.PreviewConfig] = "global TelegramBot config row",
[Constants.TableNames.Feedback] = "product feedback deliberately survives offboarding (TableFeedbackRepositoryTests contract)",
[Constants.TableNames.BlockedVersions] = "platform-wide agent version blocks",
[Constants.TableNames.GlobalNotifications] = "GA notifications; time-retention only",
[Constants.TableNames.VulnerabilityCache] = "CPE/CVE cache without tenant dimension",
[Constants.TableNames.OpsEvents] = "platform ops stream; time-retention only",
[Constants.TableNames.ImeVersionHistory] = "permanent platform archive by design",
[Constants.TableNames.RuleStats] = "per-rule daily counters; time-retention only",
[Constants.TableNames.OffboardingAudit] = "the audit trail OF the offboarding itself",
[Constants.TableNames.TenantOffboardingCustomsArchive] = "operator-driven cleanup by design",
[Constants.TableNames.BackupJobs] = "platform backup job log (PK='BackupJobs'); 365d retention",
};
/// <summary>
/// Wipes the offboarding handler performs OUTSIDE its bucket arrays (one-off calls in
/// HandleAsync). Reflection over the arrays cannot see these; keep in sync with the
/// handler's phase code when adding one.
/// </summary>
private static readonly string[] OneOffOffboardingWipes =
{
// Phase 2.F-final: deleted LAST via SafeWipe after everything else completed.
Constants.TableNames.TenantConfiguration,
};
/// <summary>
/// Tables allowed in MORE than one offboarding bucket. BootstrapSessions has main rows
/// under PK=tenantId AND CodeLookup rows under the discriminator PK.
/// </summary>
private static readonly string[] AllowedMultiBucket =
{
Constants.TableNames.BootstrapSessions,
};
[Fact]
public void TableNamesConstants_SetEquals_TableNamesAll()
{
var constants = ConstStrings(typeof(Constants.TableNames));
var all = Constants.TableNames.All;
var missingFromAll = constants.Except(all, StringComparer.Ordinal).ToList();
var orphansInAll = all.Except(constants, StringComparer.Ordinal).ToList();
Assert.True(missingFromAll.Count == 0,
"TableNames constants missing from TableNames.All (table will never be created at startup):\n - "
+ string.Join("\n - ", missingFromAll));
Assert.True(orphansInAll.Count == 0,
"TableNames.All entries without a matching constant:\n - " + string.Join("\n - ", orphansInAll));
}
[Fact]
public async Task EveryTable_HasALifecycleClassification()
{
var allTables = ConstStrings(typeof(Constants.TableNames)).ToHashSet(StringComparer.Ordinal);
var offboardWiped = OffboardingBucketUnion();
var perSession = await PerSessionCascadeTablesAsync();
var unclassified = allTables
.Except(offboardWiped, StringComparer.Ordinal)
.Except(perSession, StringComparer.Ordinal)
.Except(KeptByDesign.Keys, StringComparer.Ordinal)
.OrderBy(t => t, StringComparer.Ordinal)
.ToList();
Assert.True(unclassified.Count == 0,
"Tables without any lifecycle classification. Either add them to an offboarding wipe bucket "
+ "(TenantOffboardingHandler — check the writer's PK/RK shape first!), to the per-session "
+ "deletion manifest (DeletionManifestBuilder), or to KeptByDesign in this test WITH a reason:\n - "
+ string.Join("\n - ", unclassified));
}
[Fact]
public async Task KeptByDesign_IsDisjointFromEveryWipePath()
{
var offboardWiped = OffboardingBucketUnion();
var perSession = await PerSessionCascadeTablesAsync();
var conflicts = KeptByDesign.Keys
.Where(t => offboardWiped.Contains(t) || perSession.Contains(t))
.ToList();
Assert.True(conflicts.Count == 0,
"Tables listed as kept-by-design but ALSO wiped by offboarding or the session cascade — "
+ "one of the two claims is wrong:\n - " + string.Join("\n - ", conflicts));
}
[Fact]
public void EveryOffboardingBucketEntry_IsAKnownTableName()
{
var known = ConstStrings(typeof(Constants.TableNames)).ToHashSet(StringComparer.Ordinal);
var orphans = OffboardingBuckets()
.SelectMany(b => b.Tables.Select(t => $"{b.Name}: {t}"))
.Where(entry => !known.Contains(entry.Split(": ", 2)[1]))
.ToList();
Assert.True(orphans.Count == 0,
"Offboarding bucket entries that are not TableNames constants:\n - " + string.Join("\n - ", orphans));
}
[Fact]
public void OffboardingBuckets_AreDisjoint_ExceptDocumentedMultiMembership()
{
var buckets = OffboardingBuckets().ToList();
var violations = new List<string>();
for (var i = 0; i < buckets.Count; i++)
{
for (var j = i + 1; j < buckets.Count; j++)
{
var overlap = buckets[i].Tables
.Intersect(buckets[j].Tables, StringComparer.Ordinal)
.Except(AllowedMultiBucket, StringComparer.Ordinal)
.ToList();
violations.AddRange(overlap.Select(t => $"{t} (in {buckets[i].Name} AND {buckets[j].Name})"));
}
}
Assert.True(violations.Count == 0,
"Tables in more than one offboarding wipe bucket without a documented exception — "
+ "a double wipe with different key anchors usually means one of the two is wrong:\n - "
+ string.Join("\n - ", violations));
}
// ── Bucket derivation ────────────────────────────────────────────────────────
private sealed record Bucket(string Name, IReadOnlyList<string> Tables);
private static IEnumerable<Bucket> OffboardingBuckets()
{
yield return new Bucket("TenantPartitionTables", HandlerArray("TenantPartitionTables"));
yield return new Bucket("CompositePartitionTables", HandlerArray("CompositePartitionTables"));
yield return new Bucket("RowKeyTables", HandlerArray("RowKeyTables"));
yield return new Bucket("PropertyOnlyTables", HandlerArray("PropertyOnlyTables"));
yield return new Bucket("DiscriminatorTables",
HandlerTupleArray<(string Table, string Discriminator)>("DiscriminatorTables").Select(t => t.Table).ToList());
yield return new Bucket("ArchivedRuleTables",
HandlerTupleArray<(string Table, string Field)>("ArchivedRuleTables").Select(t => t.Table).ToList());
yield return new Bucket("OneOffOffboardingWipes", OneOffOffboardingWipes);
}
private static HashSet<string> OffboardingBucketUnion()
=> OffboardingBuckets().SelectMany(b => b.Tables).ToHashSet(StringComparer.Ordinal);
private static string[] HandlerArray(string fieldName)
=> (string[])HandlerField(fieldName).GetValue(null)!;
private static T[] HandlerTupleArray<T>(string fieldName)
=> (T[])HandlerField(fieldName).GetValue(null)!;
private static FieldInfo HandlerField(string fieldName)
=> typeof(TenantOffboardingHandler).GetField(fieldName, BindingFlags.NonPublic | BindingFlags.Static)
?? throw new InvalidOperationException(
$"TenantOffboardingHandler.{fieldName} not found — bucket array renamed? Update this test's derivation.");
/// <summary>
/// Derives the per-session cascade table set by building a REAL deletion manifest against
/// an empty-inventory fake, so new builder steps are picked up automatically. Synthetic
/// steps map back to their tables: Tombstone → Sessions + SessionsIndex,
/// SoftwareInventoryDecrement → SoftwareInventory.
/// </summary>
private static async Task<HashSet<string>> PerSessionCascadeTablesAsync()
{
var builder = new DeletionManifestBuilder(new EmptyInventoryReader(), NullLogger<DeletionManifestBuilder>.Instance);
var manifest = await builder.BuildAsync(
TenantId, SessionId, "lifecycle_bucket_test",
new DeletionActor { Type = "admin", Actor = "alice@contoso.com" },
new DeletionRetentionContext { TenantRetentionDays = 90 });
var tables = manifest.Steps
.Where(s => !string.IsNullOrEmpty(s.Table))
.Select(s => s.Table!)
.ToHashSet(StringComparer.Ordinal);
if (manifest.Steps.Any(s => s.Step == DeletionStepNames.Tombstone))
{
tables.Add(Constants.TableNames.SessionsIndex);
tables.Add(Constants.TableNames.Sessions);
}
if (manifest.Steps.Any(s => s.Step == DeletionStepNames.SoftwareInventoryDecrement))
{
tables.Add(Constants.TableNames.SoftwareInventory);
}
// The inventory pair (steps 20/21) is only emitted when a contributions side-row
// exists; the fake provides one so the derivation sees the complete step list.
Assert.Contains(Constants.TableNames.SessionInventoryContributions, tables);
return tables;
}
private static string[] ConstStrings(Type constClass)
=> constClass.GetFields(BindingFlags.Public | BindingFlags.Static)
.Where(f => f.IsLiteral && f.FieldType == typeof(string))
.Select(f => (string)f.GetRawConstantValue()!)
.ToArray();
// ── Fake inventory reader ────────────────────────────────────────────────────
private sealed class EmptyInventoryReader : ISessionDeletionInventoryReader
{
private const string IndexRowKey = "0000000000000000001_" + SessionId;
public Task<TableEntity?> GetSessionRowAsync(string tenantId, string sessionId, CancellationToken cancellationToken = default)
=> Task.FromResult<TableEntity?>(new TableEntity(tenantId, sessionId) { ["IndexRowKey"] = IndexRowKey });
public Task<TableEntity?> GetSessionsIndexRowAsync(string tenantId, string indexRowKey, CancellationToken cancellationToken = default)
=> Task.FromResult<TableEntity?>(new TableEntity(tenantId, indexRowKey) { ["SessionId"] = SessionId });
public async IAsyncEnumerable<TableEntity> QueryAsync(string tableName, string filter, [EnumeratorCancellation] CancellationToken cancellationToken = default)
{
await Task.CompletedTask;
yield break;
}
public Task<TableEntity?> GetEntityOrNullAsync(string tableName, string partitionKey, string rowKey, CancellationToken cancellationToken = default)
{
// Provide the SessionInventoryContributions side-row so steps 20/21 are emitted.
if (tableName == Constants.TableNames.SessionInventoryContributions)
{
return Task.FromResult<TableEntity?>(new TableEntity(partitionKey, rowKey));
}
return Task.FromResult<TableEntity?>(null);
}
public Task<TableEntity?> GetActiveSessionTombstoneAsync(string tenantId, string sessionId, CancellationToken cancellationToken = default)
=> Task.FromResult<TableEntity?>(null);
}
}
@@ -33,8 +33,8 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
public Task<SessionSummary?> GetSessionAsync(string tenantId, string sessionId)
=> _storage.GetSessionAsync(tenantId, sessionId);
public Task<string?> FindSessionTenantIdAsync(string sessionId)
=> _storage.FindSessionTenantIdAsync(sessionId);
public Task<string?> ResolveSessionTenantIdAsync(string sessionId)
=> _storage.ResolveSessionTenantIdAsync(sessionId);
public Task<List<SessionSummary>> GetOpenSessionsForDeviceAsync(string tenantId, string serialNumber)
=> _storage.GetOpenSessionsForDeviceAsync(tenantId, serialNumber);
@@ -38,21 +38,11 @@ namespace AutopilotMonitor.Functions.Functions.Annotations
{
try
{
// Authentication + MemberRead authorization enforced by PolicyEnforcementMiddleware
// Authentication + MemberRead authorization enforced by PolicyEnforcementMiddleware.
// Global-scope (GA or read-only GlobalReader) callers resolve the session's actual
// tenant upfront so the read works cross-tenant.
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 effectiveTenantId = await requestCtx.ResolveSessionScopeAsync(_sessionRepo, sessionId);
var annotations = await _annotationRepo.GetForSessionAsync(effectiveTenantId, sessionId);
var visible = FilterLanesForCaller(annotations, requestCtx.HasGlobalScope);
@@ -65,20 +65,14 @@ namespace AutopilotMonitor.Functions.Functions.Annotations
// 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;
}
}
// resolve the session's actual tenant like the other session reads do —
// requireGlobalAdmin because this is a WRITE (a read-only GlobalReader
// must never steer it cross-tenant). 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 = await requestCtx.ResolveSessionScopeAsync(
_sessionRepo, sessionId, requireGlobalAdmin: true);
var isOwnTenant = string.Equals(effectiveTenantId, requestCtx.TenantId, StringComparison.OrdinalIgnoreCase);
if (!IsLaneWritableByCaller(
@@ -61,26 +61,19 @@ namespace AutopilotMonitor.Functions.Functions.Metrics
// Junk/placeholder serials have no device identity — by design they never have a
// chain (disclosed in the fleet aggregate instead), so null here is honest.
//
// The primary key here is the SERIAL, not the session — a global-scope caller
// without an explicit tenantId query param can only be steered to the owning
// tenant when a sessionId travels along (point-read; mirrors
// GetSessionTimeAttribution).
var serialKey = DeviceJourneyCalculator.NormalizeSerial(rawSerial);
var effectiveTenantId = requestCtx.TargetTenantId;
var effectiveTenantId = string.IsNullOrEmpty(sessionId)
? requestCtx.TargetTenantId
: await requestCtx.ResolveSessionScopeAsync(_sessionRepo, sessionId!);
var history = serialKey == null
? null
: await _metricsRepo.GetDeviceHistoryAsync(effectiveTenantId, serialKey);
// Global-scope fallback (mirrors GetSessionTimeAttribution): a GA/operator opening
// a session without an explicit tenantId query param reads their home partition —
// resolve the owning tenant via the session and retry once.
if (history == null && serialKey != null && requestCtx.HasGlobalScope && !string.IsNullOrEmpty(sessionId))
{
var resolvedTenantId = await _sessionRepo.FindSessionTenantIdAsync(sessionId!);
if (resolvedTenantId != null &&
!string.Equals(resolvedTenantId, effectiveTenantId, StringComparison.OrdinalIgnoreCase))
{
effectiveTenantId = resolvedTenantId;
history = await _metricsRepo.GetDeviceHistoryAsync(effectiveTenantId, serialKey);
}
}
// Attempt number for the requesting session — fail-soft: a missing session or an
// empty basis yields null, never a guessed position.
int? attemptNumber = null;
@@ -42,23 +42,13 @@ namespace AutopilotMonitor.Functions.Functions.Metrics
try
{
// Authentication + MemberRead authorization enforced by PolicyEnforcementMiddleware;
// cross-tenant access via TargetTenantId (TenantScoping.QueryParam).
// cross-tenant access via TargetTenantId (TenantScoping.QueryParam). Global-scope
// callers resolve the session's owning tenant upfront (point-read; mirrors
// GetSessionEventsFunction).
var requestCtx = req.GetRequestContext();
var effectiveTenantId = await requestCtx.ResolveSessionScopeAsync(_sessionRepo, sessionId);
var breakdown = await _metricsRepo.GetSessionTimeBreakdownAsync(requestCtx.TargetTenantId, sessionId);
// Global-scope fallback (mirrors GetSessionEventsFunction): a GA/operator opening
// a session without an explicit tenantId query param reads their home partition —
// resolve the owning tenant and retry once.
if (breakdown == null && requestCtx.HasGlobalScope)
{
var resolvedTenantId = await _sessionRepo.FindSessionTenantIdAsync(sessionId);
if (resolvedTenantId != null &&
!string.Equals(resolvedTenantId, requestCtx.TargetTenantId, StringComparison.OrdinalIgnoreCase))
{
breakdown = await _metricsRepo.GetSessionTimeBreakdownAsync(resolvedTenantId, sessionId);
}
}
var breakdown = await _metricsRepo.GetSessionTimeBreakdownAsync(effectiveTenantId, sessionId);
var response = req.CreateResponse(HttpStatusCode.OK);
await response.WriteAsJsonAsync(new { success = true, breakdown });
@@ -103,7 +103,7 @@ namespace AutopilotMonitor.Functions.Functions.Raw
// throws when unauthenticated), so it never reaches this branch with an empty value.
if (string.IsNullOrEmpty(tenantId) && scope == "raw-events:global")
{
tenantId = await _sessionRepo.FindSessionTenantIdAsync(sessionId);
tenantId = await _sessionRepo.ResolveSessionTenantIdAsync(sessionId);
}
if (string.IsNullOrEmpty(tenantId))
{
@@ -89,14 +89,7 @@ namespace AutopilotMonitor.Functions.Functions.Rules
// Global-scope cross-tenant fallback: resolve the session's actual tenant so a GA /
// Global Reader can dry-run against any tenant's session (mirrors GetRuleResultsFunction).
if (requestCtx.HasGlobalScope)
{
var resolvedTenantId = await _sessionRepo.FindSessionTenantIdAsync(sessionId);
if (resolvedTenantId != null && !string.Equals(resolvedTenantId, effectiveTenantId, StringComparison.OrdinalIgnoreCase))
{
effectiveTenantId = resolvedTenantId;
}
}
effectiveTenantId = await requestCtx.ResolveSessionScopeAsync(_sessionRepo, sessionId);
var session = await _sessionRepo.GetSessionAsync(effectiveTenantId, sessionId);
if (session == null)
@@ -40,23 +40,29 @@ namespace AutopilotMonitor.Functions.Functions.Rules
[HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = "sessions/{sessionId}/analysis")] HttpRequestData req,
string sessionId)
{
// Authentication + MemberRead authorization enforced by PolicyEnforcementMiddleware
var requestCtx = req.GetRequestContext();
var effectiveTenantId = requestCtx.TargetTenantId;
// Authentication + MemberRead authorization enforced by PolicyEnforcementMiddleware.
// Global-scope (GA or read-only GlobalReader) cross-tenant fallback: resolve actual tenant
// upfront so the read works cross-tenant for any platform-scope caller.
if (requestCtx.HasGlobalScope)
{
var resolvedTenantId = await _sessionRepo.FindSessionTenantIdAsync(sessionId);
if (resolvedTenantId != null && !string.Equals(resolvedTenantId, effectiveTenantId, StringComparison.OrdinalIgnoreCase))
{
effectiveTenantId = resolvedTenantId;
}
}
var requestCtx = req.GetRequestContext();
var effectiveTenantId = await requestCtx.ResolveSessionScopeAsync(_sessionRepo, sessionId);
var reanalyze = string.Equals(req.Query["reanalyze"], "true", StringComparison.OrdinalIgnoreCase);
// Re-analysis deletes + rewrites rule results — an ACTION, not a view. The route is
// MemberRead (Viewer included) so the read stays open, but the recompute trigger is
// gated to write-capable callers (mirrors the UI hiding "Analyze now" for Viewer and
// keeps a read-only GlobalReader from steering cross-tenant writes).
if (reanalyze && !RecomputeTriggerGate.CanTriggerRecompute(requestCtx, effectiveTenantId))
{
var forbidden = req.CreateResponse(System.Net.HttpStatusCode.Forbidden);
await forbidden.WriteAsJsonAsync(new
{
success = false,
message = "Your role does not permit triggering re-analysis.",
});
return forbidden;
}
// Rules whose StoreRuleResultAsync returned false during the on-demand reanalyze.
// Reported back to the caller so the UI can render a warning banner without losing
// the rules that DID persist. Mirrors the queue-path's throw-on-false retry trigger,
@@ -51,24 +51,14 @@ namespace AutopilotMonitor.Functions.Functions.Sessions
try
{
// Global-scope (GA or read-only GlobalReader) callers resolve the session's owning
// tenant upfront (point-read; same pattern as the other session-detail reads). The
// resolved tenant also stamps the projection below.
var requestCtx = req.GetRequestContext();
var effectiveTenantId = await requestCtx.ResolveSessionScopeAsync(_sessionRepo, sessionId);
var transitions = await _transitionRepo.QueryBySessionAsync(
requestCtx.TargetTenantId, sessionId, MaxTransitionsToLoad);
// Global-scope (GA or read-only GlobalReader) cross-tenant fallback (same pattern as other session-detail reads).
var effectiveTenantId = requestCtx.TargetTenantId;
if (transitions.Count == 0 && requestCtx.HasGlobalScope)
{
var resolvedTenantId = await _sessionRepo.FindSessionTenantIdAsync(sessionId);
if (resolvedTenantId != null &&
!string.Equals(resolvedTenantId, requestCtx.TargetTenantId, StringComparison.OrdinalIgnoreCase))
{
transitions = await _transitionRepo.QueryBySessionAsync(
resolvedTenantId, sessionId, MaxTransitionsToLoad);
effectiveTenantId = resolvedTenantId;
}
}
effectiveTenantId, sessionId, MaxTransitionsToLoad);
var projection = DecisionGraphBuilder.Build(effectiveTenantId, sessionId, transitions);
@@ -83,8 +83,9 @@ namespace AutopilotMonitor.Functions.Functions.Sessions
// preview a session in a tenant other than their JWT home tenant.
// 2. JWT TargetTenantId, where the policy middleware mirrors the JWT tid for
// non-RouteParam routes. Sufficient for tenant admins acting on their own tenant.
// 3. SessionsIndex lookup keyed by sessionId — the historical fallback for the
// "global" sentinel; preserved so existing callers without ?tenantId still work.
// 3. Session-tenant lookup keyed by sessionId (point-read with legacy-scan
// fallback) — the historical fallback for the "global" sentinel; preserved so
// existing callers without ?tenantId still work.
var explicitTenantId = query["tenantId"];
string tenantId;
if (requestCtx.HasGlobalScope
@@ -98,7 +99,7 @@ namespace AutopilotMonitor.Functions.Functions.Sessions
tenantId = requestCtx.TargetTenantId;
if (string.IsNullOrEmpty(tenantId) || string.Equals(tenantId, "global", StringComparison.OrdinalIgnoreCase))
{
tenantId = await _sessionRepo.FindSessionTenantIdAsync(sessionId) ?? string.Empty;
tenantId = await _sessionRepo.ResolveSessionTenantIdAsync(sessionId) ?? string.Empty;
}
}
if (string.IsNullOrEmpty(tenantId))
@@ -98,17 +98,13 @@ namespace AutopilotMonitor.Functions.Functions.Sessions
if (pagination.PageSize == null)
{
// Legacy unpaginated path — full list, no nextLink.
var events = await _sessionRepo.GetSessionEventsAsync(requestCtx.TargetTenantId, sessionId);
if (events.Count == 0 && requestCtx.HasGlobalScope)
{
var resolvedTenantId = await _sessionRepo.FindSessionTenantIdAsync(sessionId);
if (resolvedTenantId != null && !string.Equals(resolvedTenantId, requestCtx.TargetTenantId, StringComparison.OrdinalIgnoreCase))
{
events = await _sessionRepo.GetSessionEventsAsync(resolvedTenantId, sessionId);
}
}
// Legacy unpaginated path — full list, no nextLink. Global-scope callers
// resolve the session's owning tenant UPFRONT (point-read) — the former
// "events.Count == 0 → cross-partition scan" retry conflated "session lives
// elsewhere" with "session genuinely has no events" and paid a full
// SessionsIndex scan for every empty result.
var unpaginatedTenantId = await requestCtx.ResolveSessionScopeAsync(_sessionRepo, sessionId);
var events = await _sessionRepo.GetSessionEventsAsync(unpaginatedTenantId, sessionId);
var filtered = hasFilters
? ApplyFilters(events, filterEventType, filterSeverity, filterSource).ToList()
@@ -154,13 +150,9 @@ namespace AutopilotMonitor.Functions.Functions.Sessions
// *on every page*, not just page 1, so external callers
// that strip nextLink down to the bare continuation token
// (older MCP clients, deployed agents) still validate.
// The lookup is one indexed point-read on SessionsIndex —
// The lookup is one point-read on SessionTenantLookup —
// negligible compared to the events page fetch itself.
var resolvedTenantId = await _sessionRepo.FindSessionTenantIdAsync(sessionId);
if (resolvedTenantId != null && !string.Equals(resolvedTenantId, effectiveTenantId, StringComparison.OrdinalIgnoreCase))
{
effectiveTenantId = resolvedTenantId;
}
effectiveTenantId = await requestCtx.ResolveSessionScopeAsync(_sessionRepo, sessionId);
}
}
@@ -40,22 +40,13 @@ namespace AutopilotMonitor.Functions.Functions.Sessions
try
{
// Authentication + MemberRead authorization enforced by PolicyEnforcementMiddleware
// Cross-tenant access check handled by middleware (TargetTenantId)
// Authentication + MemberRead authorization enforced by PolicyEnforcementMiddleware.
// Cross-tenant access check handled by middleware (TargetTenantId); global-scope
// callers resolve the session's owning tenant upfront (point-read).
var requestCtx = req.GetRequestContext();
var effectiveTenantId = await requestCtx.ResolveSessionScopeAsync(_sessionRepo, sessionId);
var session = await _sessionRepo.GetSessionAsync(requestCtx.TargetTenantId, sessionId);
// Global-scope (GA or read-only GlobalReader) cross-tenant fallback: if not found in the
// effective tenant, try resolving the actual tenant via SessionsIndex
if (session == null && requestCtx.HasGlobalScope)
{
var resolvedTenantId = await _sessionRepo.FindSessionTenantIdAsync(sessionId);
if (resolvedTenantId != null && !string.Equals(resolvedTenantId, requestCtx.TargetTenantId, StringComparison.OrdinalIgnoreCase))
{
session = await _sessionRepo.GetSessionAsync(resolvedTenantId, sessionId);
}
}
var session = await _sessionRepo.GetSessionAsync(effectiveTenantId, sessionId);
if (session == null)
{
@@ -68,33 +68,17 @@ namespace AutopilotMonitor.Functions.Functions.Sessions
try
{
// Cross-tenant scope resolved upfront (point-read; same pattern as the other
// session detail reads). The GlobalReadOrAdmin route gate restricts callers to
// platform scope, which is exactly what the helper's HasGlobalScope gate checks.
var requestCtx = req.GetRequestContext();
var tenantIdForLoad = requestCtx.TargetTenantId;
var tenantIdForLoad = await requestCtx.ResolveSessionScopeAsync(_sessionRepo, sessionId);
var signals = await _signalRepo.QueryBySessionAsync(
tenantIdForLoad, sessionId, MaxSignalsToLoad);
var transitions = await _transitionRepo.QueryBySessionAsync(
tenantIdForLoad, sessionId, MaxTransitionsToLoad);
// Global-scope cross-tenant fallback — same pattern as the other session detail read
// endpoints. Resolve via SessionsIndex when the effective tenant has no data. (The
// GlobalReadOrAdmin gate already restricts this route to platform-scope callers — GA or
// read-only GlobalReader — so we don't re-check the role here; the SessionsIndex lookup
// is the cleanest way to find the actual tenant for a loose sessionId.)
if (signals.Count == 0 && transitions.Count == 0)
{
var resolvedTenantId = await _sessionRepo.FindSessionTenantIdAsync(sessionId);
if (resolvedTenantId != null &&
!string.Equals(resolvedTenantId, tenantIdForLoad, StringComparison.OrdinalIgnoreCase))
{
signals = await _signalRepo.QueryBySessionAsync(
resolvedTenantId, sessionId, MaxSignalsToLoad);
transitions = await _transitionRepo.QueryBySessionAsync(
resolvedTenantId, sessionId, MaxTransitionsToLoad);
tenantIdForLoad = resolvedTenantId;
}
}
// Read the live reducer's version via a transient DecisionEngine instance.
// The engine has no heavy construction cost (no DI, no state) so this is
// cheaper than threading a singleton through.
@@ -57,21 +57,14 @@ namespace AutopilotMonitor.Functions.Functions.Sessions
try
{
// Global-scope (GA or read-only GlobalReader) callers resolve the session's owning
// tenant upfront (point-read) — mirrors GetSessionEventsFunction; the former
// "Count == 0 → scan" retry treated empty results as wrong-tenant evidence.
var requestCtx = req.GetRequestContext();
var effectiveTenantId = await requestCtx.ResolveSessionScopeAsync(_sessionRepo, sessionId);
var signals = await _signalRepo.QueryBySessionAsync(
requestCtx.TargetTenantId, sessionId, maxResults);
// Global-scope (GA or read-only GlobalReader) cross-tenant fallback — mirror GetSessionEventsFunction pattern.
if (signals.Count == 0 && requestCtx.HasGlobalScope)
{
var resolvedTenantId = await _sessionRepo.FindSessionTenantIdAsync(sessionId);
if (resolvedTenantId != null &&
!string.Equals(resolvedTenantId, requestCtx.TargetTenantId, StringComparison.OrdinalIgnoreCase))
{
signals = await _signalRepo.QueryBySessionAsync(resolvedTenantId, sessionId, maxResults);
}
}
effectiveTenantId, sessionId, maxResults);
return await req.OkAsync(new
{
@@ -78,7 +78,7 @@ namespace AutopilotMonitor.Functions.Functions.Sessions
// 1. Explicit body.TenantId on GA calls (cross-tenant restore — the common case
// when restoring from the Session Cleanup admin page).
// 2. JWT TargetTenantId (single-tenant operators).
// 3. SessionsIndex lookup keyed by sessionId (rare but preserved).
// 3. Session-tenant lookup keyed by sessionId (rare but preserved).
// 4. body.TenantId again — last-resort fallback for the "Sessions row gone" path
// where neither the index nor the JWT tenant can resolve the target.
var requestCtx = req.GetRequestContext();
@@ -95,7 +95,7 @@ namespace AutopilotMonitor.Functions.Functions.Sessions
tenantId = requestCtx.TargetTenantId;
if (string.IsNullOrEmpty(tenantId) || string.Equals(tenantId, "global", StringComparison.OrdinalIgnoreCase))
{
tenantId = await _sessionRepo.FindSessionTenantIdAsync(sessionId) ?? string.Empty;
tenantId = await _sessionRepo.ResolveSessionTenantIdAsync(sessionId) ?? string.Empty;
}
if (string.IsNullOrEmpty(tenantId))
{
@@ -63,6 +63,15 @@ public class SearchSessionsFunction
var filter = BuildFilter(query);
// Free-text minimum: a 1-char substring matches nearly every row and turns the
// backfill loop into a full scan for nothing (mirrors search/quick's minimum).
if (filter.Q != null && filter.Q.Length < 2)
{
var bad = req.CreateResponse(HttpStatusCode.BadRequest);
await bad.WriteAsJsonAsync(new { success = false, message = "q must be at least 2 characters" });
return bad;
}
var pagination = SearchSessionsPagination.ParsePagination(query);
if (pagination.Error != null)
{
@@ -141,6 +150,8 @@ public class SearchSessionsFunction
AgentVersionPrefix = query["agentVersionPrefix"],
ImeAgentVersionPrefix = query["imeAgentVersionPrefix"],
ConnectionType = query["connectionType"],
// Free-text search (dashboard search box); trimmed, empty → null.
Q = string.IsNullOrWhiteSpace(query["q"]) ? null : query["q"]!.Trim(),
// Limit field is a no-op in the paged path — pagination drives count.
};
@@ -49,22 +49,27 @@ namespace AutopilotMonitor.Functions.Functions.Vulnerability
// Cross-tenant access check handled by middleware (TargetTenantId)
var requestCtx = req.GetRequestContext();
var userIdentifier = requestCtx.UserPrincipalName;
var effectiveTenantId = requestCtx.TargetTenantId;
// Global-scope (GA or read-only GlobalReader) cross-tenant fallback: resolve actual tenant
// upfront so the read works cross-tenant for any platform-scope caller.
if (requestCtx.HasGlobalScope)
{
var resolvedTenantId = await _sessionRepo.FindSessionTenantIdAsync(sessionId);
if (resolvedTenantId != null && !string.Equals(resolvedTenantId, effectiveTenantId, StringComparison.OrdinalIgnoreCase))
{
effectiveTenantId = resolvedTenantId;
}
}
var effectiveTenantId = await requestCtx.ResolveSessionScopeAsync(_sessionRepo, sessionId);
var query = HttpUtility.ParseQueryString(req.Url.Query);
var rescan = string.Equals(query["rescan"], "true", StringComparison.OrdinalIgnoreCase);
// A re-scan regenerates + stores the report — an ACTION on a MemberRead route;
// gate it to write-capable callers (see RecomputeTriggerGate).
if (rescan && !RecomputeTriggerGate.CanTriggerRecompute(requestCtx, effectiveTenantId))
{
var forbidden = req.CreateResponse(System.Net.HttpStatusCode.Forbidden);
await forbidden.WriteAsJsonAsync(new
{
success = false,
message = "Your role does not permit triggering a vulnerability re-scan.",
});
return forbidden;
}
if (rescan)
{
try
@@ -0,0 +1,29 @@
using AutopilotMonitor.Shared;
namespace AutopilotMonitor.Functions.Helpers;
/// <summary>
/// Gate for on-demand recompute triggers that ride on MemberRead GET routes
/// (<c>?reanalyze=true</c> on sessions/{id}/analysis, <c>?rescan=true</c> on
/// sessions/{id}/vulnerability-report). Recomputes delete + rewrite stored results —
/// they are ACTIONS, not views: Viewer and the read-only Global Reader see the results
/// but must not trigger regeneration (decided 2026-08-13 with the Viewer tier; the UI
/// hides the buttons, this is the API-side enforcement).
/// </summary>
public static class RecomputeTriggerGate
{
/// <summary>
/// True when the caller may trigger a recompute for <paramref name="effectiveTenantId"/>:
/// a Global Admin anywhere, or an OWN-TENANT Admin/Operator. Viewer, Global Reader, and
/// delegated (read-tier) callers are excluded, as is any tenant role applied cross-tenant.
/// </summary>
public static bool CanTriggerRecompute(RequestContext ctx, string effectiveTenantId)
{
if (ctx.IsGlobalAdmin)
return true;
var isOwnTenant = string.Equals(effectiveTenantId, ctx.TenantId, StringComparison.OrdinalIgnoreCase);
return isOwnTenant
&& (ctx.UserRole == Constants.TenantRoles.Admin || ctx.UserRole == Constants.TenantRoles.Operator);
}
}
@@ -1,4 +1,5 @@
using AutopilotMonitor.Shared;
using AutopilotMonitor.Shared.DataAccess;
using Microsoft.Azure.Functions.Worker;
using Microsoft.Azure.Functions.Worker.Http;
@@ -146,6 +147,37 @@ public static class RequestContextExtensions
|| ctx.UserRole == Constants.TenantRoles.Operator
|| ctx.UserRole == Constants.TenantRoles.Viewer;
/// <summary>
/// Resolves the effective tenantId for a session-scoped request (Fragilitätsaudit P5.3 —
/// the one implementation of the former 15-site copy-paste fallback).
/// <para>
/// Non-global callers always keep <see cref="RequestContext.TargetTenantId"/> (the middleware
/// already validated it; no cross-tenant reach). Global-scope callers resolve the session's
/// owning tenant via <see cref="ISessionRepository.ResolveSessionTenantIdAsync"/> (point-read
/// with legacy-scan fallback) and get the resolved tenant when it differs; an unknown session
/// keeps <see cref="RequestContext.TargetTenantId"/> so each endpoint's own not-found handling
/// (404 / empty 200 / 400) stays in charge.
/// </para>
/// </summary>
/// <param name="requireGlobalAdmin">
/// True on WRITE paths: gate the cross-tenant resolve on <see cref="RequestContext.IsGlobalAdmin"/>
/// instead of <see cref="RequestContext.HasGlobalScope"/>, so a read-only Global Reader can never
/// steer a write into a foreign tenant.
/// </param>
public static async Task<string> ResolveSessionScopeAsync(
this RequestContext ctx, ISessionRepository sessionRepo, string sessionId, bool requireGlobalAdmin = false)
{
var canReachAcrossTenants = requireGlobalAdmin ? ctx.IsGlobalAdmin : ctx.HasGlobalScope;
if (!canReachAcrossTenants)
return ctx.TargetTenantId;
var resolvedTenantId = await sessionRepo.ResolveSessionTenantIdAsync(sessionId);
if (resolvedTenantId != null && !string.Equals(resolvedTenantId, ctx.TargetTenantId, StringComparison.OrdinalIgnoreCase))
return resolvedTenantId;
return ctx.TargetTenantId;
}
/// <summary>Gets the correlation ID for this request (set by CorrelationIdMiddleware).</summary>
public static string GetCorrelationId(this FunctionContext context)
{
@@ -55,6 +55,7 @@ namespace AutopilotMonitor.Functions.Pagination
new KeyValuePair<string, string?>("rebootCountMin", filter.RebootCountMin?.ToString(CultureInfo.InvariantCulture)),
new KeyValuePair<string, string?>("rebootCountMax", filter.RebootCountMax?.ToString(CultureInfo.InvariantCulture)),
new KeyValuePair<string, string?>("connectionType", filter.ConnectionType),
new KeyValuePair<string, string?>("q", filter.Q),
};
if (filter.DeviceProperties != null)
{
@@ -135,8 +135,13 @@ namespace AutopilotMonitor.Functions.Services.Deletion
AddContributionsRowStep(manifest, order: 21, contributionsRow);
}
// ---- Step 22: Tombstone (SessionsIndex first, then Sessions). ----
AddTombstoneStep(manifest, order: 22, sessionsIndexRow, sessionRow);
// ---- Step 22: sessionId → tenantId lookup row (PK=sessionId, RK="tenant"). ----
// Deleted just before the tombstone so cross-tenant resolution keeps working for
// as long as the session itself exists; absent for legacy sessions (0-row step).
await AddPkRkExactStepAsync(manifest, order: 22, table: Constants.TableNames.SessionTenantLookup, partitionKey: sessionId, rowKey: "tenant", cancellationToken);
// ---- Step 23: Tombstone (SessionsIndex first, then Sessions). ----
AddTombstoneStep(manifest, order: 23, sessionsIndexRow, sessionRow);
// PreflightCounts derive from each step's RowCount, plus the AGGREGATE decrements length.
manifest.PreflightCounts = ComputePreflightCounts(manifest);
@@ -66,5 +66,16 @@ namespace AutopilotMonitor.Functions.Services.Offboarding
SecurityValidator.EnsureValidGuid(normalizedTenantId, nameof(normalizedTenantId));
return $"TenantId eq '{ODataSanitizer.EscapeValue(normalizedTenantId)}'";
}
/// <summary>
/// <c>RowKey eq '{tenantId}'</c> — Variant D SafeWipe (RowKey-anchored full-table
/// filter). For tables keyed <c>PK=&lt;non-tenant dimension&gt;, RK=tenantId</c>
/// (UsageMetrics: PK=date). Full-table scan; reserve for retention-bounded tables.
/// </summary>
public static string RowKeyEquals(string normalizedTenantId)
{
SecurityValidator.EnsureValidGuid(normalizedTenantId, nameof(normalizedTenantId));
return $"RowKey eq '{ODataSanitizer.EscapeValue(normalizedTenantId)}'";
}
}
}
@@ -118,6 +118,26 @@ namespace AutopilotMonitor.Functions.Services.Offboarding
ct);
}
/// <summary>
/// Variant D (RowKey-anchored full-table filter). For tables where the tenant id is
/// the ROW key and the partition key is a non-tenant dimension (UsageMetrics:
/// PK=date, RK=tenantId|"global"). Full-table scan — reserve for retention-bounded
/// tables.
/// </summary>
public virtual Task<int> WipeByRowKeyAsync(
string tableName, string normalizedTenantId, CancellationToken ct = default)
{
SecurityValidator.EnsureValidGuid(normalizedTenantId, nameof(normalizedTenantId));
var filter = OffboardingFilters.RowKeyEquals(normalizedTenantId);
return RunFetchVerifyDeleteAsync(
tableName,
filter,
expectedAnchor: $"RowKey={normalizedTenantId}",
verifyRow: e => string.Equals(e.RowKey, normalizedTenantId, StringComparison.Ordinal),
ct);
}
/// <summary>
/// Blob-prefix wipe. Lists blobs under <c>{normalizedTenantId}/</c> in the named
/// container, verifies each returned name starts with the prefix, then deletes
@@ -56,7 +56,6 @@ namespace AutopilotMonitor.Functions.Services.Offboarding
private static readonly string[] TenantPartitionTables =
{
Constants.TableNames.AuditLogs,
Constants.TableNames.UsageMetrics,
Constants.TableNames.UserActivity,
Constants.TableNames.UserPresence,
Constants.TableNames.AppInstallSummaries,
@@ -93,6 +92,22 @@ namespace AutopilotMonitor.Functions.Services.Offboarding
// 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,
// Cumulative per-tenant counters (PK=tenantId, RK="current"). The platform row
// lives under PK="global", which can never match a tenant GUID, so the exact-PK
// wipe removes only the offboarded tenant's partition.
Constants.TableNames.PlatformStats,
// Intune script display-name cache (PK=tenantId). Only had TTL eviction before;
// tenant script names must not outlive the tenant.
Constants.TableNames.ScriptNameCache,
};
// Variant D — RowKey-anchored wipes for tables whose ROW key is the tenant id.
// UsageMetrics rows are written as PK=date ("yyyy-MM-dd"), RK=tenantId|"global"
// (SaveUsageMetricsSnapshotAsync) — an exact-PK wipe here matched 0 rows, so
// per-tenant snapshots silently survived offboarding until the 180d retention sweep.
private static readonly string[] RowKeyTables =
{
Constants.TableNames.UsageMetrics,
};
// Plan §6.4 — composite-PK "{tenantId}_..." wipes (Variant A range).
@@ -956,6 +971,11 @@ namespace AutopilotMonitor.Functions.Services.Offboarding
ct.ThrowIfCancellationRequested();
counts[table] = await _safeWipe.WipeByTenantIdPropertyAsync(table, tenantId, ct);
}
foreach (var table in RowKeyTables)
{
ct.ThrowIfCancellationRequested();
counts[table] = await _safeWipe.WipeByRowKeyAsync(table, tenantId, ct);
}
}
// ── Failure path ────────────────────────────────────────────────────────
@@ -0,0 +1,133 @@
using System;
using System.Collections.Generic;
using System.Linq;
namespace AutopilotMonitor.Functions.Services
{
/// <summary>
/// The declared SessionsIndex column set — single source for what the FULL-MIRROR index
/// row carries (see <c>TableStorageService.BuildSessionIndexEntity</c>, which is pinned
/// against this manifest bidirectionally by <c>SessionIndexFieldManifestTests</c>).
///
/// Maintenance flow for a NEW mirrored field:
/// 1. add it to <see cref="AlwaysProjected"/> (written with a default on every rebuild)
/// or <see cref="ConditionallyProjected"/> (written only when present),
/// 2. write it in <c>BuildSessionIndexEntity</c> with matching semantics,
/// 3. read it in <c>MapToSessionSummary</c>.
/// Skipping any step turns the manifest tests red. Merge sites are guarded fail-soft:
/// <c>MergeSessionIndexAsync</c> logs a warning for keys outside <see cref="All"/> —
/// a merged-but-not-projected field is exactly the recurring StartedAt-shift drift bug
/// (e.g. ab90423b).
/// </summary>
internal static class SessionIndexFieldManifest
{
/// <summary>
/// Written on every index rebuild with a default (never absent from an index row).
/// Note: <c>CurrentPhaseDetail</c> is a dead column — no Sessions writer sets it, so
/// it is always empty; kept for row-shape stability until deliberately retired.
/// </summary>
public static readonly string[] AlwaysProjected =
{
"SessionId",
"SerialNumber",
"DeviceName",
"Manufacturer",
"Model",
"StartedAt",
"Status",
"CurrentPhase",
"CurrentPhaseDetail",
"EventCount",
"EnrollmentType",
"IsPreProvisioned",
"IsHybridJoin",
"IsUserDriven",
"IsSelfDeployingProfile",
"IsCloudPc",
"AgentVersion",
"ImeAgentVersion",
"OsName",
"OsBuild",
"OsDisplayVersion",
"OsEdition",
"OsLanguage",
"GeoCountry",
"GeoRegion",
"GeoCity",
"GeoLoc",
"PlatformScriptCount",
"RemediationScriptCount",
"RebootCount",
"ExcessiveEventsAlerted",
"ExcessiveEventsAutoActioned",
};
/// <summary>Written only when the Sessions row carries a value (absent otherwise).</summary>
public static readonly string[] ConditionallyProjected =
{
"CompletedAt",
"FailureReason",
"FailureSource",
"ReconcileReason",
"EspSoftFailure",
"CompletionSource",
"ValidatedBy",
"FailureSnapshotJson",
"AdminMarkedAction",
"DurationSeconds",
"DiagnosticsBlobName",
"DiagnosticsBlobDestination",
"LastEventAt",
"ResumedAt",
"StalledAt",
"AvgApiLatencyMs",
"ApiRequestCount",
"ConnectionType",
};
/// <summary>Every column an index row may carry (data columns; PK/RK excluded).</summary>
public static readonly string[] All =
AlwaysProjected.Concat(ConditionallyProjected).ToArray();
/// <summary>
/// Sessions-row fields owned by separate write subsystems that deliberately do NOT
/// touch the index (ServerActions queue + deletion CAS). Sessions served from the
/// index read these as defaults; routing them through the index sync is a tracked
/// follow-up, not an accident.
/// </summary>
public static readonly string[] PrimaryOnly =
{
"PendingActionsJson",
"PendingActionsQueuedAt",
"DeletionState",
"PendingDeletionManifestId",
};
private static readonly HashSet<string> AllSet = new(All, StringComparer.Ordinal);
/// <summary>
/// System keys a merge entity may legitimately carry besides data columns.
/// </summary>
private static readonly HashSet<string> SystemKeys = new(StringComparer.Ordinal)
{
"PartitionKey", "RowKey", "Timestamp", "odata.etag",
};
/// <summary>
/// Returns the data keys of <paramref name="mergeEntity"/> that are NOT part of the
/// manifest — i.e. fields a merge site writes that a StartedAt-shift full rebuild
/// would silently drop. Callers log these as a warning (fail-soft).
/// </summary>
public static List<string> FindNonManifestKeys(Azure.Data.Tables.TableEntity mergeEntity)
{
List<string>? offenders = null;
foreach (var kvp in mergeEntity)
{
if (SystemKeys.Contains(kvp.Key) || AllSet.Contains(kvp.Key))
continue;
(offenders ??= new List<string>()).Add(kvp.Key);
}
return offenders ?? new List<string>();
}
}
}
@@ -507,28 +507,50 @@ namespace AutopilotMonitor.Functions.Services
: await SearchSessionsByScanPageAsync(tenantId, filter, pageSize, continuation);
}
/// <summary>
/// Cap on the number of Azure pages one free-text (<c>q=</c>) request may walk while
/// backfilling matches. Free-text matches can be very sparse (one serial in a large
/// tenant), so without backfill every response would be a near-empty page and the
/// client would pay one round-trip per Azure page — the filter-after-pagination trap.
/// When the cap is hit the accumulated matches return with a nextLink so the caller
/// continues from an exact page boundary (no gaps).
/// </summary>
internal const int FreeTextBackfillMaxRounds = 10;
private async Task<RawPage<SessionSummary>> SearchSessionsByScanPageAsync(
string? tenantId, SessionSearchFilter filter, int pageSize, string? continuation)
{
var indexTableClient = _tableServiceClient.GetTableClient(Constants.TableNames.SessionsIndex);
var oDataFilter = BuildSearchScanFilter(tenantId, filter);
var (entities, nextRawToken) = await AzureTablesPaginator.FetchPageAsync<TableEntity>(
client: indexTableClient,
filter: oDataFilter,
pageSize: pageSize,
continuation: continuation);
// Free-text queries backfill whole Azure pages until pageSize matches accumulated
// (bounded rounds); non-q queries keep the single-page contract ("consume until
// absent" — a page may contain fewer than pageSize items after client filters).
var maxRounds = string.IsNullOrEmpty(filter.Q) ? 1 : FreeTextBackfillMaxRounds;
var sessions = new List<SessionSummary>(entities.Count);
foreach (var entity in entities)
var sessions = new List<SessionSummary>(pageSize);
var token = continuation;
string? nextRawToken = null;
for (var round = 0; round < maxRounds; round++)
{
var session = MapIndexEntityToSessionSummary(entity);
if (!MatchesScanClientFilters(session, filter)) continue;
sessions.Add(session);
var (entities, next) = await AzureTablesPaginator.FetchPageAsync<TableEntity>(
client: indexTableClient,
filter: oDataFilter,
pageSize: pageSize,
continuation: token);
foreach (var entity in entities)
{
var session = MapIndexEntityToSessionSummary(entity);
if (!MatchesScanClientFilters(session, filter)) continue;
sessions.Add(session);
}
nextRawToken = next;
token = next;
if (token == null || sessions.Count >= pageSize)
break;
}
// Note: a page may legitimately contain fewer than pageSize items after
// client-side filters; callers should follow nextLink until absent for
// forensics-grade exact-count semantics (Plan §"consume until absent").
return new RawPage<SessionSummary>(sessions, nextRawToken);
}
@@ -688,8 +710,41 @@ namespace AutopilotMonitor.Functions.Services
return string.Equals(session.ConnectionType, filter.ConnectionType, StringComparison.OrdinalIgnoreCase);
}
/// <summary>
/// Free-text predicate for the dashboard search box (<c>q=</c>). Case-insensitive
/// substring over EXACTLY the fields the web dashboard's client-side filter searches
/// (useDashboardFilters.searchableText, minus its derived-only tokens: localized date
/// string, "N min" duration, "blocked"). Keep the two lists in sync — a field matched
/// only server-side would be filtered back out by the client and read as a ghost result.
/// </summary>
internal static bool MatchesFreeText(SessionSummary session, string? q)
{
if (string.IsNullOrEmpty(q)) return true;
return ContainsIgnoreCase(session.DeviceName, q)
|| ContainsIgnoreCase(session.SerialNumber, q)
|| ContainsIgnoreCase(session.Manufacturer, q)
|| ContainsIgnoreCase(session.Model, q)
|| ContainsIgnoreCase(session.Status.ToString(), q)
|| ContainsIgnoreCase(session.SessionId, q)
|| ContainsIgnoreCase(session.GeoCountry, q)
|| ContainsIgnoreCase(session.GeoRegion, q)
|| ContainsIgnoreCase(session.GeoCity, q)
|| ContainsIgnoreCase(session.AgentVersion, q)
|| ContainsIgnoreCase(session.OsName, q)
|| ContainsIgnoreCase(session.OsBuild, q)
|| ContainsIgnoreCase(session.OsDisplayVersion, q)
|| ContainsIgnoreCase(session.OsEdition, q)
|| ContainsIgnoreCase(session.OsLanguage, q);
}
private static bool ContainsIgnoreCase(string? haystack, string needle)
=> !string.IsNullOrEmpty(haystack)
&& haystack!.IndexOf(needle, StringComparison.OrdinalIgnoreCase) >= 0;
private static bool MatchesScanClientFilters(SessionSummary session, SessionSearchFilter filter)
{
if (!MatchesFreeText(session, filter.Q)) return false;
if (!string.IsNullOrEmpty(filter.SerialNumber) &&
!string.Equals(session.SerialNumber, filter.SerialNumber, StringComparison.OrdinalIgnoreCase))
return false;
@@ -978,6 +1033,7 @@ namespace AutopilotMonitor.Functions.Services
// query can't return non-matching sessions.
if (!MatchesRebootCountBounds(s, filter)) return false;
if (!MatchesConnectionType(s, filter)) return false;
if (!MatchesFreeText(s, filter.Q)) return false;
return true;
}).ToList();
}
@@ -49,7 +49,9 @@ namespace AutopilotMonitor.Functions.Services
/// back in the Sessions entity. Copies all SessionSummary-relevant fields so that
/// listing queries only need to hit the index table.
/// </summary>
private async Task UpsertSessionIndexAsync(TableEntity sessionEntity, DateTime startedAt)
// internal (not private) so SessionIndexFieldManifestTests can pin the Replace-mode
// full-rebuild semantics + the stale-RowKey delete without a live table service.
internal async Task UpsertSessionIndexAsync(TableEntity sessionEntity, DateTime startedAt)
{
try
{
@@ -77,7 +79,11 @@ namespace AutopilotMonitor.Functions.Services
// Build index entity from the single SessionsIndex field manifest (lean read-model contract).
var indexEntity = BuildSessionIndexEntity(sessionEntity, indexRowKey, startedAt);
await indexTableClient.UpsertEntityAsync(indexEntity);
// Replace, not merge: the full rebuild is the mirror's truth. Merge-mode upsert
// could not clear conditional fields the primary meanwhile blanked (e.g. a
// FailureReason cleared on re-Succeed) — the stale column would survive every
// non-shift rebuild (re-registration, backfill) forever.
await indexTableClient.UpsertEntityAsync(indexEntity, TableUpdateMode.Replace);
// Store IndexRowKey back in the Sessions entity so Merge-mode updates can find it
var sessionsTableClient = _tableServiceClient.GetTableClient(Constants.TableNames.Sessions);
@@ -100,10 +106,12 @@ namespace AutopilotMonitor.Functions.Services
/// do). It serves the session list / search / stats AND the <c>/api/raw/sessions</c> endpoint
/// (which returns every stored index column verbatim, see <c>RawEntityProjection</c>) directly
/// — no hydration from Sessions — so it must carry the same column set the session lifecycle
/// writes. This builder is the ONE place that set is defined; it must stay a SUPERSET of every
/// field any <c>MergeSessionIndexAsync</c> call site writes, otherwise a StartedAt-shift full
/// upsert would drop a merged field until the next merge (the recurring drift bug, e.g.
/// ab90423b). Keep aligned with <c>MapToSessionSummary</c> (read side).
/// writes. The declared column set lives in <see cref="SessionIndexFieldManifest"/>; this
/// builder is pinned against it bidirectionally by <c>SessionIndexFieldManifestTests</c> and
/// must stay a SUPERSET of every field any <c>MergeSessionIndexAsync</c> call site writes
/// (guarded fail-soft there), otherwise a StartedAt-shift full upsert would drop a merged
/// field until the next merge (the recurring drift bug, e.g. ab90423b). Keep aligned with
/// <c>MapToSessionSummary</c> (read side — also pinned by the manifest tests).
///
/// Not mirrored here: fields owned by separate write subsystems that do not touch the index —
/// <c>PendingActionsJson</c>/<c>PendingActionsQueuedAt</c> (ServerActions) and
@@ -265,6 +273,17 @@ namespace AutopilotMonitor.Functions.Services
indexUpdate[kvp.Key] = kvp.Value;
}
// Fail-soft drift guard: a merged field missing from the manifest (and thus
// from BuildSessionIndexEntity) is silently dropped on the next StartedAt-shift
// full rebuild — the recurring drift bug. Write it anyway, but surface the gap.
var nonManifestKeys = SessionIndexFieldManifest.FindNonManifestKeys(indexUpdate);
if (nonManifestKeys.Count > 0)
{
_logger.LogWarning(
"SessionsIndex merge for {TenantId}/{IndexRowKey} writes field(s) outside SessionIndexFieldManifest: {Fields} — add them to the manifest + BuildSessionIndexEntity or a full rebuild will drop them",
tenantId, indexRowKey, string.Join(", ", nonManifestKeys));
}
await indexTableClient.UpdateEntityAsync(indexUpdate, ETag.All, TableUpdateMode.Merge);
}
catch (RequestFailedException ex) when (ex.Status == 404)
@@ -730,6 +749,10 @@ namespace AutopilotMonitor.Functions.Services
// Dual-write: upsert into SessionsIndex for time-sorted listing
await UpsertSessionIndexAsync(entity, startedAt);
// Dual-write: sessionId → tenantId point-read lookup (fail-soft; legacy
// sessions heal lazily in ResolveSessionTenantIdAsync).
await UpsertSessionTenantLookupAsync(registration.SessionId, registration.TenantId);
_logger.LogInformation($"Stored session {registration.SessionId}");
return true;
}
@@ -1497,14 +1520,52 @@ namespace AutopilotMonitor.Functions.Services
}
}
/// <summary>RowKey of the single lookup row per session in <see cref="Constants.TableNames.SessionTenantLookup"/>.</summary>
private const string SessionTenantLookupRowKey = "tenant";
/// <summary>
/// Finds the tenantId for a session by scanning SessionsIndex.
/// Used for Global Admin cross-tenant session lookup when tenantId is unknown.
/// Resolves the tenantId owning a session. Point-read on the SessionTenantLookup table
/// (PK=sessionId); sessions registered before the table existed fall back to the legacy
/// cross-partition SessionsIndex scan and self-heal by writing the lookup row, so the
/// scan cost is paid at most once per legacy session. Used for Global Admin cross-tenant
/// session access when tenantId is unknown.
/// </summary>
public async Task<string?> FindSessionTenantIdAsync(string sessionId)
public async Task<string?> ResolveSessionTenantIdAsync(string sessionId)
{
SecurityValidator.EnsureValidGuid(sessionId, nameof(sessionId));
try
{
var lookupClient = _tableServiceClient.GetTableClient(Constants.TableNames.SessionTenantLookup);
var response = await lookupClient.GetEntityAsync<TableEntity>(sessionId, SessionTenantLookupRowKey);
var tenantId = response.Value.GetString("TenantId");
if (!string.IsNullOrEmpty(tenantId))
return tenantId;
}
catch (RequestFailedException ex) when (ex.Status == 404)
{
// Legacy session (pre-lookup-table) or already deleted — fall through to the scan.
}
catch (Exception ex)
{
_logger.LogWarning(ex, "SessionTenantLookup point-read failed for {SessionId}, falling back to index scan", sessionId);
}
var scanned = await ScanSessionsIndexForTenantIdAsync(sessionId);
if (scanned != null)
{
// Self-heal: next resolve for this legacy session is a point-read.
await UpsertSessionTenantLookupAsync(sessionId, scanned);
}
return scanned;
}
/// <summary>
/// Legacy fallback: finds the tenantId by scanning SessionsIndex cross-partition
/// (SessionId is a non-key property there). Reserve for lookup-table misses.
/// </summary>
private async Task<string?> ScanSessionsIndexForTenantIdAsync(string sessionId)
{
try
{
var indexTableClient = _tableServiceClient.GetTableClient(Constants.TableNames.SessionsIndex);
@@ -1523,6 +1584,27 @@ namespace AutopilotMonitor.Functions.Services
}
}
/// <summary>
/// Writes the sessionId → tenantId lookup row (fail-soft: resolution still works via
/// the scan fallback when this write is lost).
/// </summary>
private async Task UpsertSessionTenantLookupAsync(string sessionId, string tenantId)
{
try
{
var lookupClient = _tableServiceClient.GetTableClient(Constants.TableNames.SessionTenantLookup);
var row = new TableEntity(sessionId, SessionTenantLookupRowKey)
{
["TenantId"] = tenantId
};
await lookupClient.UpsertEntityAsync(row);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to upsert SessionTenantLookup for {SessionId}", sessionId);
}
}
/// <summary>
/// Updates the session status and current phase.
/// Uses Merge mode to write only changed fields, reducing ETag conflicts under concurrency.
@@ -2116,6 +2198,21 @@ namespace AutopilotMonitor.Functions.Services
if (resumedAt.HasValue)
forceUpdate["ResumedAt"] = EnsureUtc(resumedAt.Value);
// Mirror the stalled-state bookkeeping + heal from the normal path —
// otherwise the force-merge fallback drops a stall marker (or keeps a
// healed session visually stalled) exactly when ETag retries are
// exhausted at the stall/heal transition.
if (stalledAt.HasValue)
{
forceUpdate["StalledAt"] = EnsureUtc(stalledAt.Value);
if (!string.IsNullOrEmpty(failureReason))
forceUpdate["FailureReason"] = failureReason;
}
if (clearStalledAt)
forceUpdate["StalledAt"] = (DateTime?)null;
if (clearFailureReason)
forceUpdate["FailureReason"] = string.Empty;
// Unconditional merge write — ETag.All bypasses concurrency check
await forceTableClient.UpdateEntityAsync(forceUpdate, ETag.All, TableUpdateMode.Merge);
@@ -1119,6 +1119,16 @@ namespace AutopilotMonitor.Shared
// annotations are hand-labeled rule-quality data.
public const string SessionAnnotations = "SessionAnnotations";
// Point-read lookup sessionId → tenantId (Fragilitätsaudit P6.1). Replaces the
// cross-partition SessionsIndex scan the global-scope endpoints paid to resolve a
// session's owning tenant.
// - PartitionKey = sessionId, RowKey = "tenant", property TenantId
// Written on session registration; legacy sessions are healed lazily (scan
// fallback writes the row on first resolve). Cascade-deleted with the session
// (manifest step right before the tombstone); rebuildable from Sessions, so
// NOT in the critical-backup set.
public const string SessionTenantLookup = "SessionTenantLookup";
/// <summary>
/// Returns all table names for initialization
/// </summary>
@@ -1188,6 +1198,7 @@ namespace AutopilotMonitor.Shared
BackupJobs,
ConfigurationBackups,
SessionAnnotations,
SessionTenantLookup,
};
}
@@ -17,7 +17,13 @@ namespace AutopilotMonitor.Shared.DataAccess
// --- Session CRUD ---
Task<bool> StoreSessionAsync(SessionRegistration registration);
Task<SessionSummary?> GetSessionAsync(string tenantId, string sessionId);
Task<string?> FindSessionTenantIdAsync(string sessionId);
/// <summary>
/// Resolves the tenantId owning <paramref name="sessionId"/> — point-read on the
/// SessionTenantLookup table with a legacy SessionsIndex-scan fallback that self-heals
/// the lookup row. Null when the session is unknown. Used for global-scope cross-tenant
/// session access when tenantId is not supplied (see RequestContextExtensions.ResolveSessionScopeAsync).
/// </summary>
Task<string?> ResolveSessionTenantIdAsync(string sessionId);
/// <summary>
/// Returns all sessions for <paramref name="tenantId"/> newest-first.
/// Optional <paramref name="days"/> scopes to the last N days. No row cap —
@@ -43,6 +43,14 @@ namespace AutopilotMonitor.Shared.Models
// property and are excluded (acceptable — they have no connection data).
public string? ConnectionType { get; set; }
// Free-text search (dashboard search box). Case-insensitive substring match,
// applied server-side AFTER the OData scan, across the same field set the web
// dashboard's client-side filter uses (device name, serial, manufacturer, model,
// status, sessionId, geo, agent version, OS fields). Cannot be pushed to OData —
// Azure Tables has no substring operator — so the scan path backfills pages
// until it has pageSize matches (bounded rounds).
public string? Q { get; set; }
public int Limit { get; set; } = 50;
// Dynamic device property filters (key = "eventType.propertyName", value = filter expression)
@@ -79,6 +79,8 @@ interface SessionTableProps {
loadingMore: boolean;
loadingAll: boolean;
onLoadAll: () => void;
/** Server-side search sweep for the current search query (dropdown "Search all sessions"). */
onSearchAll: () => void;
adminMode: boolean;
globalAdminMode: boolean;
tenantIdFilter: string;
@@ -130,6 +132,7 @@ export function SessionTable({
loadingMore,
loadingAll,
onLoadAll,
onSearchAll,
adminMode,
globalAdminMode,
tenantIdFilter,
@@ -558,7 +561,7 @@ export function SessionTable({
<button
onClick={() => {
setShowSearchSuggestions(false);
onLoadAll();
onSearchAll();
}}
className="text-xs font-medium text-green-700 hover:text-green-800 transition-colors"
>
@@ -7,6 +7,7 @@ import { authenticatedFetch, TokenExpiredError } from "@/lib/authenticatedFetch"
import { extractContinuation } from "@/lib/paginationLink";
import { asGuidOrUndefined } from "@/utils/inputValidation";
import { boundTenantToDelegatedScope } from "@/utils/delegatedScope";
import { mergeSessionsById } from "@/lib/sessionSearchMerge";
import { isHomeTenantTarget } from "@/utils/homeTenantScope";
import { hasTenantReadScope } from "@/lib/tenantScope";
import type { NotificationType } from "@/contexts/NotificationContext";
@@ -15,6 +16,14 @@ import type { Session } from "../types";
const DEFAULT_PAGE_SIZE = 10;
const MAX_PAGE_SIZE = 1000;
// Server-side search sweep bounds: the backend scans up to 10 Azure pages per request
// (free-text backfill) and returns only matches; the client follows nextLink until it has
// a comfortable result set or the cap is reached — instead of the former loadAll() walk
// that downloaded the ENTIRE session history to search it client-side.
const SEARCH_PAGE_SIZE = 100;
const SEARCH_MAX_REQUESTS = 10;
const SEARCH_TARGET_MATCHES = 50;
type AddNotification = (
type: NotificationType,
title: string,
@@ -74,6 +83,13 @@ export interface UseDashboardSessionsReturn {
refetchWith: (tenantIdOverride: string) => void;
loadMore: () => void;
loadAll: () => void;
/**
* Server-side search sweep for the dashboard search box: fetches only sessions matching
* the query (backend `q=` filter) and merges them into the loaded list, instead of
* downloading the whole history like loadAll(). Falls back to loadAll() for delegated
* (MSP) callers in cross-tenant mode — the global search route has no delegated tier.
*/
searchAll: (query: string) => void;
removeSession: (sessionId: string) => void;
}
@@ -367,6 +383,70 @@ export function useDashboardSessions({
}
}, [fetchSessionsBatch, continuationRef]);
// Server-side search sweep — see UseDashboardSessionsReturn.searchAll. Shares the
// fetchLock/loadAllToken discipline with loadAll() so refetch/unmount cancels it and
// the two sweeps can never interleave.
const searchAll = useCallback(async (query: string) => {
const q = query.trim();
if (q.length < 2 || fetchLockRef.current) return;
const rawFilter = submittedTenantIdFilterRef.current.trim();
const homeSelected = isDelegatedScopeRef.current &&
isHomeTenantTarget(asGuidOrUndefined(rawFilter), tenantIdRef.current ?? undefined);
const useGlobal = globalAdminModeRef.current && !homeSelected;
// Delegated (MSP) callers in cross-tenant mode have no access to the global search
// route (GlobalReadOrAdmin) — keep their previous full-sweep behavior.
if (useGlobal && isDelegatedScopeRef.current) {
await loadAll();
return;
}
fetchLockRef.current = true;
const myToken = ++loadAllTokenRef.current;
setLoadingMore(true);
setLoadingAll(true);
try {
const effectiveTenantFilter = boundTenantToDelegatedScope(
asGuidOrUndefined(rawFilter), isDelegatedScopeRef.current, delegatedTenantIdsRef.current);
let continuation: string | undefined;
let matches = 0;
for (let round = 0; round < SEARCH_MAX_REQUESTS && loadAllTokenRef.current === myToken; round++) {
const endpoint = useGlobal
? api.globalSessions.search(q, effectiveTenantFilter, { pageSize: SEARCH_PAGE_SIZE, continuation })
: api.sessions.search(q, { pageSize: SEARCH_PAGE_SIZE, continuation });
const response = await authenticatedFetch(endpoint, getAccessToken);
if (!response.ok) {
console.error(`Server-side session search failed (${response.status})`);
break;
}
const data = await response.json();
if (loadAllTokenRef.current !== myToken) break;
const found: Session[] = data.sessions || [];
if (found.length > 0) {
matches += found.length;
setSessions((prev) => mergeSessionsById(prev, found));
}
const next = extractContinuation(data.nextLink);
if (!next || matches >= SEARCH_TARGET_MATCHES) break;
continuation = next;
}
} catch (error) {
if (error instanceof TokenExpiredError) {
addNotification("error", "Session Expired", error.message, "session-expired-error");
} else {
console.error("Server-side session search failed:", error);
}
} finally {
fetchLockRef.current = false;
setLoadingMore(false);
setLoadingAll(false);
}
}, [getAccessToken, addNotification, loadAll, globalAdminModeRef, tenantIdRef, isDelegatedScopeRef, delegatedTenantIdsRef]);
const removeSession = useCallback((sessionId: string) => {
setSessions((prev) => prev.filter((s) => s.sessionId !== sessionId));
}, []);
@@ -538,6 +618,7 @@ export function useDashboardSessions({
refetchWith,
loadMore,
loadAll,
searchAll,
removeSession,
};
}
@@ -139,7 +139,7 @@ function HomeContent() {
const {
sessions, loading, hasMore, loadingMore, loadingAll,
refetch, refetchWith, loadMore, loadAll, removeSession,
refetch, refetchWith, loadMore, loadAll, searchAll, removeSession,
} = useDashboardSessions({
user, tenantId, globalAdminMode: crossTenant, joinGlobalAdmins, tenantIdFilter, adminMode,
getAccessToken, addNotification, setBlockedDevicesSet, signalR,
@@ -287,7 +287,8 @@ function HomeContent() {
}
}, [sessions.length, currentPage, sessionsPerPage, hasMore, loading, loadingMore, loadMore]);
// Auto-load ALL remaining sessions when search is active and local results are insufficient.
// Server-search when search is active and local results are insufficient — fetches only
// matching sessions (backend q= filter) instead of the former loadAll() full-history walk.
// Uses a 500ms debounce so rapid typing doesn't trigger unnecessary loads.
const autoLoadTimerRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
useEffect(() => {
@@ -301,11 +302,11 @@ function HomeContent() {
if (filteredSessions.length >= 8) return;
autoLoadTimerRef.current = setTimeout(() => {
loadAll();
searchAll(query);
}, 500);
return () => { if (autoLoadTimerRef.current) clearTimeout(autoLoadTimerRef.current); };
}, [searchQuery, filteredSessions.length, hasMore, loading, loadingMore, loadAll]);
}, [searchQuery, filteredSessions.length, hasMore, loading, loadingMore, searchAll]);
const applyTenantIdFilter = (value: string) => {
setTenantIdFilter(value);
@@ -562,6 +563,7 @@ function HomeContent() {
loadingMore={loadingMore}
loadingAll={loadingAll}
onLoadAll={loadAll}
onSearchAll={() => searchAll(searchQuery)}
adminMode={adminMode}
globalAdminMode={crossTenant}
tenantIdFilter={tenantIdFilter}
@@ -70,7 +70,7 @@ function DiagnosisContent() {
try {
// Fetch the single session directly instead of pulling the entire session
// list and .find()-ing client-side. The backend resolves the tenant via
// FindSessionTenantIdAsync for global admins when tenantId is omitted.
// ResolveSessionTenantIdAsync for global admins when tenantId is omitted.
const knownTenantId = sessionTenantId || (!globalAdminMode ? tenantId : null);
const endpoint =
knownTenantId && isGuid(knownTenantId)
@@ -100,7 +100,7 @@ export function useSessionDetail({
try {
const knownTenantId = resolveEffectiveTenantId();
// Always use the direct session endpoint — the backend resolves the tenant
// via FindSessionTenantIdAsync for global admins when tenantId is unknown.
// via ResolveSessionTenantIdAsync for global admins when tenantId is unknown.
const endpoint = knownTenantId
? api.sessions.get(sessionId, knownTenantId)
: api.sessions.get(sessionId);
@@ -1,6 +1,6 @@
"use client";
import { createContext, useCallback, useContext, useEffect, useState, type SetStateAction } from "react";
import { createContext, useCallback, useContext, useEffect, useMemo, useState, type SetStateAction } from "react";
import { useRouter } from "next/navigation";
import { useTenant } from "../../contexts/TenantContext";
import { useAuth } from "../../contexts/AuthContext";
@@ -1628,8 +1628,13 @@ export function TenantConfigProvider({ children }: { children: React.ReactNode }
// -----------------------------------------------------------------------
// Provider value
// -----------------------------------------------------------------------
return (
<TenantConfigContext.Provider value={{
// Memoized (P6.3): the provider re-renders whenever ANY upstream context emits
// (Auth account refresh, a notification anywhere in the app, tenant/theme) — without
// the memo every such render minted a fresh value object and re-rendered all 16
// settings sections for nothing. Own-state changes still fan out (the value honestly
// depends on nearly every field); the memo only cuts the externally-triggered cascade.
// The dependency array is enforced by react-hooks/exhaustive-deps (lint cap = 0).
const value = useMemo<TenantConfigContextValue>(() => ({
config, loading, canEditConfig, savingSection,
error, setError, successMessage, setSuccessMessage,
@@ -1745,7 +1750,47 @@ export function TenantConfigProvider({ children }: { children: React.ReactNode }
// Auth
user, getAccessToken,
}}>
}), [
config, loading, canEditConfig, savingSection, error, successMessage,
editionInfo, startingTrial, startTrial, appHomingFunnelActive, homingFlipped,
validateAutopilotDevice, validateCorporateIdentifier, validateDeviceAssociation,
handleToggleDeviceAssociationValidation, validateCloudPcDevice, handleToggleCloudPcValidation,
saveValidationGate, autopilotConsentInProgress, beginDeviceValidationConsentFlow, detectExistingAccess,
manufacturerWhitelist, modelWhitelist, effectiveHwRejectionNotify, setHwRejectionNotifyWriteThrough,
handleSaveHardwareWhitelist, handleResetHardwareWhitelist,
enablePerformanceCollector, performanceCollectorInterval, helloWaitTimeoutSeconds,
selfDestructOnComplete, keepLogFile, rebootOnComplete, rebootDelaySeconds,
contactEmail, handleSaveContact, handleResetContact,
enableGeoLocation, enableTimezoneAutoSet, enableImeMatchLog, enableGatherRuleDebugLog,
logLevel, showScriptOutput, showEnrollmentSummary, enrollmentSummaryTimeoutSeconds,
enrollmentSummaryBrandingImageUrl, enrollmentSummaryLaunchRetrySeconds,
handleSaveAgentSettings, handleResetAgentSettings,
enableLocalAdminAnalyzer, localAdminAllowedAccounts, newAllowedAccount,
enableSoftwareInventoryAnalyzer, enableIntegrityBypassAnalyzer, enableRealmJoinWatcher,
keepAwakeDuringUserEsp, enableConsoleBypassDetection,
handleSaveAgentAnalyzers, handleResetAgentAnalyzers,
unrestrictedMode, handleSaveUnrestrictedMode,
notificationChannels, testingChannelId, testChannelResult,
handleTestChannel, handleSaveNotifications, handleResetNotifications,
slaTargetSuccessRate, slaTargetMaxDurationMinutes, slaTargetAppInstallSuccessRate,
slaNotifyOnSuccessRateBreach, slaSuccessRateNotifyThreshold, slaNotifyOnDurationBreach,
slaNotifyOnAppInstallBreach, slaNotifyOnConsecutiveFailures, slaConsecutiveFailureThreshold,
handleSaveSlaTargets, handleResetSlaTargets,
diagnosticsBlobSasUrl, diagnosticsUploadMode, diagnosticsUploadDestination,
tenantDiagPaths, globalDiagPaths, newDiagPath, newDiagDesc,
handleSaveDiagnostics, handleResetDiagnostics,
admins, loadingAdmins, newAdminEmail, newMemberRole, addingAdmin, removingAdmin, togglingAdmin,
adminSearchQuery, currentAdminPage,
handleAddAdmin, handleRemoveAdmin, handleToggleTenantAdmin, handleUpdatePermissions,
bootstrapSessions, bootstrapLoading, fetchBootstrapSessions, createBootstrapSession, revokeBootstrapSession,
dataRetentionDays, sessionTimeoutHours, handleSaveDataManagement, handleResetDataManagement,
showOffboardDialog, offboardConfirmText, offboarding, offboardError, handleOffboard,
offboardingInProgress, handleDrainBarrierElapsed,
user, getAccessToken,
]);
return (
<TenantConfigContext.Provider value={value}>
{children}
</TenantConfigContext.Provider>
);
@@ -1,6 +1,6 @@
"use client";
import React, { createContext, useContext, useEffect, useState, useCallback, useRef } from 'react';
import React, { createContext, useContext, useEffect, useMemo, useState, useCallback, useRef } from 'react';
import { PublicClientApplication, AccountInfo, InteractionStatus, InteractionRequiredAuthError, BrowserAuthError } from '@azure/msal-browser';
import { MsalProvider, useMsal, useIsAuthenticated } from '@azure/msal-react';
import { msalConfig, loginRequest, apiRequest, activeAuthApp, buildMsalConfig, clientIdForApp } from '@/lib/msalConfig';
@@ -531,7 +531,10 @@ function AuthProviderInternal({ children }: { children: React.ReactNode }) {
}
}, [instance, accounts]);
const value: AuthContextType = {
// Memoized (P6.3): AuthProvider sits above every page — an unmemoized value object
// re-rendered every context consumer on each provider render (MSAL account refreshes
// included), which cascaded into the settings/dashboard trees.
const value: AuthContextType = useMemo(() => ({
isAuthenticated,
user,
hasGlobalScope: (user?.isGlobalAdmin || user?.isGlobalReader) ?? false,
@@ -543,7 +546,7 @@ function AuthProviderInternal({ children }: { children: React.ReactNode }) {
logout,
getAccessToken,
refreshUserInfo,
};
}), [isAuthenticated, user, isLoading, isActivationPending, activationMessage, login, logout, getAccessToken, refreshUserInfo]);
return (
<AuthContext.Provider value={value}>
@@ -1,6 +1,6 @@
"use client";
import React, { createContext, useContext, useState, useCallback } from 'react';
import React, { createContext, useContext, useState, useCallback, useMemo } from 'react';
export type NotificationType = 'error' | 'warning' | 'info' | 'success';
@@ -119,7 +119,10 @@ export function NotificationProvider({ children }: { children: React.ReactNode }
const unreadCount = notifications.filter(n => !n.read).length;
const value: NotificationContextType = {
// Memoized (P6.3): every addNotification anywhere in the app re-renders this provider;
// without the memo that minted a fresh value object and re-rendered every consumer even
// when nothing they read changed.
const value: NotificationContextType = useMemo(() => ({
notifications,
unreadCount,
addNotification,
@@ -127,7 +130,7 @@ export function NotificationProvider({ children }: { children: React.ReactNode }
markAllAsRead,
removeNotification,
clearAll,
};
}), [notifications, unreadCount, addNotification, markAsRead, markAllAsRead, removeNotification, clearAll]);
return (
<NotificationContext.Provider value={value}>
@@ -1,6 +1,6 @@
"use client";
import React, { createContext, useContext, useState, useEffect } from 'react';
import React, { createContext, useContext, useState, useEffect, useMemo } from 'react';
import { useAuth } from './AuthContext';
interface TenantContextType {
@@ -53,8 +53,12 @@ export function TenantProvider({ children }: { children: React.ReactNode }) {
setTenantId(authTenantId);
}
// Memoized (P6.3): this provider re-renders on every AuthContext emission — without
// the memo each of those minted a fresh value object for every tenant consumer.
const value = useMemo(() => ({ tenantId, setTenantId }), [tenantId]);
return (
<TenantContext.Provider value={{ tenantId, setTenantId }}>
<TenantContext.Provider value={value}>
{children}
</TenantContext.Provider>
);
@@ -1,6 +1,6 @@
"use client";
import { createContext, useContext, useEffect, useSyncExternalStore } from "react";
import { createContext, useCallback, useContext, useEffect, useMemo, useSyncExternalStore } from "react";
import {
Theme,
getServerThemeSnapshot,
@@ -30,12 +30,15 @@ export function ThemeProvider({ children }: { children: React.ReactNode }) {
document.documentElement.classList.toggle("dark", theme === "dark");
}, [theme]);
const toggleTheme = () => {
const toggleTheme = useCallback(() => {
setStoredTheme(theme === "dark" ? "light" : "dark");
};
}, [theme]);
// Memoized (P6.3): stable value identity while the theme is unchanged.
const value = useMemo(() => ({ theme, toggleTheme }), [theme, toggleTheme]);
return (
<ThemeContext.Provider value={{ theme, toggleTheme }}>
<ThemeContext.Provider value={value}>
{children}
</ThemeContext.Provider>
);
@@ -0,0 +1,32 @@
import { describe, expect, it } from "vitest";
import { mergeSessionsById } from "../sessionSearchMerge";
// P6.2: server-search results merge into the loaded dashboard list — existing items
// (possibly fresher via SignalR) always win, new matches append, no duplicates.
describe("mergeSessionsById", () => {
const a = { sessionId: "a", status: "InProgress" };
const b = { sessionId: "b", status: "Failed" };
const c = { sessionId: "c", status: "Succeeded" };
it("appends only unknown sessions", () => {
const merged = mergeSessionsById([a, b], [b, c]);
expect(merged.map((s) => s.sessionId)).toEqual(["a", "b", "c"]);
});
it("keeps the existing copy on sessionId collision", () => {
const staleB = { sessionId: "b", status: "InProgress" };
const merged = mergeSessionsById([a, b], [staleB]);
expect(merged).toHaveLength(2);
expect(merged.find((s) => s.sessionId === "b")?.status).toBe("Failed");
});
it("returns the SAME array reference when nothing new arrived (no-op re-render guard)", () => {
const existing = [a, b];
expect(mergeSessionsById(existing, [])).toBe(existing);
expect(mergeSessionsById(existing, [a, b])).toBe(existing);
});
it("handles an empty existing list", () => {
expect(mergeSessionsById([], [a, c]).map((s) => s.sessionId)).toEqual(["a", "c"]);
});
});
+17
View File
@@ -73,6 +73,14 @@ export const api = {
`${API_BASE_URL}/api/sessions/${sessionId}/annotations/${lane}${qs({ tenantId })}`,
quickSearch: (q: string) =>
`${API_BASE_URL}/api/search/quick${qs({ q })}`,
// Server-side free-text search (dashboard search box) — substring across the same
// fields the client-side filter uses; JWT-bound tenant. Paginated like list().
search: (q: string, opts?: { pageSize?: number; continuation?: string }) =>
`${API_BASE_URL}/api/search/sessions${qs({
q,
pageSize: opts?.pageSize?.toString(),
continuation: opts?.continuation,
})}`,
// NOTE: lives under /api/stats/sessions (NOT /api/sessions/stats) — Azure
// Functions' router lets a literal "sessions/stats" be eaten by the sibling
// "sessions/{sessionId}" function, which then 404s on "stats" not parsing
@@ -109,6 +117,15 @@ export const api = {
tenantId: opts?.tenantId,
days: opts?.days?.toString(),
})}`,
// Cross-tenant variant of sessions.search; tenantId narrows to one tenant (filter,
// not authorization — the route is GlobalReadOrAdmin).
search: (q: string, tenantId?: string, opts?: { pageSize?: number; continuation?: string }) =>
`${API_BASE_URL}/api/global/search/sessions${qs({
q,
tenantId,
pageSize: opts?.pageSize?.toString(),
continuation: opts?.continuation,
})}`,
},
// ── Config ────────────────────────────────────────────────────────────────
@@ -0,0 +1,23 @@
/**
* Merge helper for server-side session search results (Fragilitätsaudit P6.2).
*
* Search results arrive out-of-order relative to the loaded (newest-first) list and may
* overlap with already-loaded or SignalR-updated sessions. The merge keeps every existing
* item untouched (the loaded copy may be fresher — live updates mutate it) and appends only
* genuinely new sessions; the dashboard's existing sort chain re-orders afterwards.
*
* Returns the ORIGINAL array reference when nothing new arrived so React state consumers
* skip a no-op re-render.
*/
export function mergeSessionsById<T extends { sessionId: string }>(
existing: T[],
incoming: T[],
): T[] {
if (incoming.length === 0) return existing;
const known = new Set(existing.map((s) => s.sessionId));
const fresh = incoming.filter((s) => !known.has(s.sessionId));
if (fresh.length === 0) return existing;
return [...existing, ...fresh];
}