diff --git a/docs/backend/lifecycle-manifests-and-session-scope.md b/docs/backend/lifecycle-manifests-and-session-scope.md new file mode 100644 index 00000000..9eb7190a --- /dev/null +++ b/docs/backend/lifecycle-manifests-and-session-scope.md @@ -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) diff --git a/docs/index.md b/docs/index.md index 927676ea..890d2177 100644 --- a/docs/index.md +++ b/docs/index.md @@ -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 diff --git a/docs/log.md b/docs/log.md index b1a9a34f..72e6f2eb 100644 --- a/docs/log.md +++ b/docs/log.md @@ -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`. diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/DeletionManifestBuilderTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/DeletionManifestBuilderTests.cs index 4e32ebdc..fdb347db 100644 --- a/src/Backend/AutopilotMonitor.Functions.Tests/DeletionManifestBuilderTests.cs +++ b/src/Backend/AutopilotMonitor.Functions.Tests/DeletionManifestBuilderTests.cs @@ -75,9 +75,9 @@ public class DeletionManifestBuilderTests Assert.Equal(2, c["classifierVerdictsByIdLevel"]); Assert.Equal(11, c["signalsByKind"]); - // 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 _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(); @@ -833,6 +846,8 @@ public class DeletionManifestBuilderTests .ReturnsAsync(_sessionTimeBreakdown); reader.Setup(r => r.GetEntityOrNullAsync(Constants.TableNames.SessionInventoryContributions, TenantId, SessionId, It.IsAny())) .ReturnsAsync(_sessionInventoryContributions); + reader.Setup(r => r.GetEntityOrNullAsync(Constants.TableNames.SessionTenantLookup, SessionId, "tenant", It.IsAny())) + .ReturnsAsync(_sessionTenantLookup); foreach (var (lane, row) in _sessionAnnotations) { reader.Setup(r => r.GetEntityOrNullAsync(Constants.TableNames.SessionAnnotations, TenantId, $"{SessionId}_{lane}", It.IsAny())) diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/Offboarding/TenantOffboardingHandlerDrainContractTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/Offboarding/TenantOffboardingHandlerDrainContractTests.cs index fc3012c9..d33405a4 100644 --- a/src/Backend/AutopilotMonitor.Functions.Tests/Offboarding/TenantOffboardingHandlerDrainContractTests.cs +++ b/src/Backend/AutopilotMonitor.Functions.Tests/Offboarding/TenantOffboardingHandlerDrainContractTests.cs @@ -640,6 +640,12 @@ public class TenantOffboardingHandlerDrainContractTests return Task.FromResult(0); } + public override Task WipeByRowKeyAsync(string tableName, string normalizedTenantId, CancellationToken ct = default) + { + WipeCallCount++; + return Task.FromResult(0); + } + public override Task WipeBlobsByTenantPrefixAsync(string containerName, string normalizedTenantId, CancellationToken ct = default) { WipeCallCount++; diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/Offboarding/TenantOffboardingHandlerOrderingTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/Offboarding/TenantOffboardingHandlerOrderingTests.cs index aafec345..2a7886f0 100644 --- a/src/Backend/AutopilotMonitor.Functions.Tests/Offboarding/TenantOffboardingHandlerOrderingTests.cs +++ b/src/Backend/AutopilotMonitor.Functions.Tests/Offboarding/TenantOffboardingHandlerOrderingTests.cs @@ -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 PropertyOnlyWipes { get; } = new(); /// Table names passed to the exact-partition (Variant A) wipe — lets tests assert coverage. public List ExactPartitionWipes { get; } = new(); + /// Table names passed to the RowKey-anchored (Variant D) wipe — lets tests assert coverage. + public List RowKeyWipes { get; } = new(); public CountingSafeWipeService() : base( new TableStorageService(Mock.Of(), NullLogger.Instance), new BlobStorageService(new BlobServiceClient("UseDevelopmentStorage=true"), @@ -451,6 +480,7 @@ public class TenantOffboardingHandlerOrderingTests public override Task WipeByCompositePartitionRangeAsync(string t, string i, CancellationToken c = default) { WipeCallCount++; return Task.FromResult(0); } public override Task WipeByDiscriminatorAndTenantPropertyAsync(string t, string d, string i, CancellationToken c = default) { WipeCallCount++; return Task.FromResult(0); } public override Task WipeByTenantIdPropertyAsync(string t, string i, CancellationToken c = default) { WipeCallCount++; PropertyOnlyWipes.Add(t); return Task.FromResult(0); } + public override Task WipeByRowKeyAsync(string t, string i, CancellationToken c = default) { WipeCallCount++; RowKeyWipes.Add(t); return Task.FromResult(0); } public override Task WipeBlobsByTenantPrefixAsync(string c, string i, CancellationToken ct = default) { WipeCallCount++; return Task.FromResult(0); } } diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/OffboardingFiltersTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/OffboardingFiltersTests.cs index 6eab625b..e81a955e 100644 --- a/src/Backend/AutopilotMonitor.Functions.Tests/OffboardingFiltersTests.cs +++ b/src/Backend/AutopilotMonitor.Functions.Tests/OffboardingFiltersTests.cs @@ -55,6 +55,14 @@ public class OffboardingFiltersTests Assert.Throws(() => OffboardingFilters.TenantIdProperty(input)); } + [Theory] + [InlineData("not-a-guid")] + [InlineData("")] + public void RowKeyEquals_ThrowsForNonGuid(string input) + { + Assert.Throws(() => 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] diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/SafeWipeServiceTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/SafeWipeServiceTests.cs index 26732e34..15edbe66 100644 --- a/src/Backend/AutopilotMonitor.Functions.Tests/SafeWipeServiceTests.cs +++ b/src/Backend/AutopilotMonitor.Functions.Tests/SafeWipeServiceTests.cs @@ -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(() => + 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( () => harness.Sut.WipeByTenantIdPropertyAsync("t", "not-a-guid")); + await Assert.ThrowsAsync( + () => harness.Sut.WipeByRowKeyAsync("t", "not-a-guid")); } // ── Harness ───────────────────────────────────────────────────────────────── diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/SessionAnnotationFunctionsTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/SessionAnnotationFunctionsTests.cs index 35bb1b67..3749cd72 100644 --- a/src/Backend/AutopilotMonitor.Functions.Tests/SessionAnnotationFunctionsTests.cs +++ b/src/Backend/AutopilotMonitor.Functions.Tests/SessionAnnotationFunctionsTests.cs @@ -103,7 +103,7 @@ public class SessionAnnotationFunctionsTests annotationRepo.Setup(r => r.GetForSessionAsync(It.IsAny(), It.IsAny())) .ReturnsAsync(new List()); var sessionRepo = new Mock(MockBehavior.Loose); - sessionRepo.Setup(r => r.FindSessionTenantIdAsync(SessionId)).ReturnsAsync(TenantId); + sessionRepo.Setup(r => r.ResolveSessionTenantIdAsync(SessionId)).ReturnsAsync(TenantId); return new GetSessionAnnotationsFunction( NullLogger.Instance, annotationRepo.Object, sessionRepo.Object); @@ -291,7 +291,7 @@ public class SessionAnnotationFunctionsTests var sessionRepo = new Mock(MockBehavior.Loose); sessionRepo.Setup(r => r.GetSessionAsync(It.IsAny(), It.IsAny())) .ReturnsAsync(new SessionSummary { SessionId = SessionId, TenantId = TenantId }); - sessionRepo.Setup(r => r.FindSessionTenantIdAsync(SessionId)).ReturnsAsync(TenantId); + sessionRepo.Setup(r => r.ResolveSessionTenantIdAsync(SessionId)).ReturnsAsync(TenantId); var ruleRepo = new Mock(MockBehavior.Loose); ruleRepo.Setup(r => r.GetRuleResultsAsync(It.IsAny(), It.IsAny())) diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/SessionEventsPaginationTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/SessionEventsPaginationTests.cs index 1244c49e..2a150a3b 100644 --- a/src/Backend/AutopilotMonitor.Functions.Tests/SessionEventsPaginationTests.cs +++ b/src/Backend/AutopilotMonitor.Functions.Tests/SessionEventsPaginationTests.cs @@ -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. diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/SessionFreeTextSearchTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/SessionFreeTextSearchTests.cs new file mode 100644 index 00000000..f2f7eeec --- /dev/null +++ b/src/Backend/AutopilotMonitor.Functions.Tests/SessionFreeTextSearchTests.cs @@ -0,0 +1,84 @@ +using System; +using AutopilotMonitor.Functions.Pagination; +using AutopilotMonitor.Functions.Services; +using AutopilotMonitor.Shared.Models; +using Xunit; + +namespace AutopilotMonitor.Functions.Tests; + +/// +/// Fragilitätsaudit P6.2: server-side free-text search (q=) 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 +/// TableStorageService.MatchesFreeText. +/// +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); + } +} diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/SessionIndexFieldManifestTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/SessionIndexFieldManifestTests.cs new file mode 100644 index 00000000..6a159402 --- /dev/null +++ b/src/Backend/AutopilotMonitor.Functions.Tests/SessionIndexFieldManifestTests.cs @@ -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; + +/// +/// Fragilitätsaudit P5.2: is the declared SessionsIndex +/// column set; builder, reader, and merge guard are all pinned against it here. +/// +/// Builder ↔ manifest bidirectional: a field added to BuildSessionIndexEntity but +/// not the manifest fails, and vice versa. +/// Reader coverage: every manifest field must influence MapToSessionSummary output — +/// a projected-but-never-served column is dead weight; a served-but-not-projected one is +/// the drift bug. +/// Full-rebuild upsert is Replace-mode, so conditional fields the primary cleared do not +/// survive as stale index columns. +/// +/// +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" }; + + /// + /// 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). + /// + private static readonly Dictionary 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(); + 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(e => e.RowKey.EndsWith("_" + SessionId, StringComparison.Ordinal)), + TableUpdateMode.Replace, + It.IsAny()), + Times.Once); + // IndexRowKey written back onto the Sessions row (merge). + harness.Sessions.Verify(t => t.UpdateEntityAsync( + It.Is(e => e.PartitionKey == TenantId && e.RowKey == SessionId), + It.IsAny(), + TableUpdateMode.Merge, + It.IsAny()), + 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(), It.IsAny()), + Times.Once); + } + + // ── Helpers ────────────────────────────────────────────────────────────────── + + /// + /// 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. + /// + 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 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(), NullLogger.Instance); + + private sealed class UpsertHarness + { + public Mock Sessions { get; } = new(); + public Mock Index { get; } = new(); + public TableStorageService Sut { get; } + + public UpsertHarness() + { + Index + .Setup(t => t.UpsertEntityAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(Mock.Of()); + Index + .Setup(t => t.DeleteEntityAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(Mock.Of()); + Sessions + .Setup(t => t.UpdateEntityAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(Mock.Of()); + + var serviceClient = new Mock(); + 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.Instance); + } + } +} diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/SessionScopeResolutionTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/SessionScopeResolutionTests.cs new file mode 100644 index 00000000..af8cc9d5 --- /dev/null +++ b/src/Backend/AutopilotMonitor.Functions.Tests/SessionScopeResolutionTests.cs @@ -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; + +/// +/// Fragilitätsaudit P5.3/P6.1: RequestContextExtensions.ResolveSessionScopeAsync is the +/// single implementation of the former 15-site copy-paste session-scope fallback, and +/// TableStorageService.ResolveSessionTenantIdAsync 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. +/// +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(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(); + 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(); + 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(); + 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(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(); + 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(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( + It.IsAny(), It.IsAny(), It.IsAny>(), It.IsAny()), + 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(e => e.PartitionKey == SessionId + && e.RowKey == "tenant" + && e.GetString("TenantId") == OtherTenant), + It.IsAny(), + It.IsAny()), + Times.Once); + } + + [Fact] + public async Task UnknownSession_ReturnsNull_AndWritesNoHealRow() + { + var harness = new ResolveHarness(lookupRow: null, indexRows: Array.Empty()); + + var result = await harness.Sut.ResolveSessionTenantIdAsync(SessionId); + + Assert.Null(result); + harness.Lookup.Verify(t => t.UpsertEntityAsync( + It.IsAny(), It.IsAny(), It.IsAny()), + Times.Never); + } + + [Fact] + public async Task InvalidSessionId_Throws_BeforeAnyStorageCall() + { + var harness = new ResolveHarness(lookupRow: null); + + await Assert.ThrowsAsync( + () => 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 Lookup { get; } = new(); + public Mock Index { get; } = new(); + public TableStorageService Sut { get; } + + public ResolveHarness(TableEntity? lookupRow, TableEntity[]? indexRows = null) + { + if (lookupRow != null) + { + Lookup + .Setup(t => t.GetEntityAsync(SessionId, "tenant", It.IsAny>(), It.IsAny())) + .ReturnsAsync(Response.FromValue(lookupRow, Mock.Of())); + } + else + { + Lookup + .Setup(t => t.GetEntityAsync(SessionId, "tenant", It.IsAny>(), It.IsAny())) + .ThrowsAsync(new RequestFailedException(404, "Not Found")); + } + Lookup + .Setup(t => t.UpsertEntityAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(Mock.Of()); + + var page = Page.FromValues(indexRows ?? Array.Empty(), null, Mock.Of()); + Index + .Setup(t => t.QueryAsync( + It.IsAny(), It.IsAny(), It.IsAny>(), It.IsAny())) + .Returns(AsyncPageable.FromPages(new[] { page })); + + var serviceClient = new Mock(); + 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.Instance); + } + } +} diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/TableLifecycleBucketTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/TableLifecycleBucketTests.cs new file mode 100644 index 00000000..74f39f85 --- /dev/null +++ b/src/Backend/AutopilotMonitor.Functions.Tests/TableLifecycleBucketTests.cs @@ -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; + +/// +/// Lifecycle-completeness net for (Fragilitätsaudit P5.1). +/// Every table must be covered by at least one deletion lifecycle — tenant-offboarding wipe +/// (read via reflection from 's bucket arrays), the +/// per-session deletion cascade (derived from a real +/// manifest), or the explicit 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 +/// new TableEntity(pk, rk) call before picking the bucket. +/// +public class TableLifecycleBucketTests +{ + private const string TenantId = "11111111-1111-1111-1111-111111111111"; + private const string SessionId = "22222222-2222-2222-2222-222222222222"; + + /// + /// 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". + /// + private static readonly Dictionary 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", + }; + + /// + /// 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. + /// + private static readonly string[] OneOffOffboardingWipes = + { + // Phase 2.F-final: deleted LAST via SafeWipe after everything else completed. + Constants.TableNames.TenantConfiguration, + }; + + /// + /// Tables allowed in MORE than one offboarding bucket. BootstrapSessions has main rows + /// under PK=tenantId AND CodeLookup rows under the discriminator PK. + /// + 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(); + + 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 Tables); + + private static IEnumerable 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 OffboardingBucketUnion() + => OffboardingBuckets().SelectMany(b => b.Tables).ToHashSet(StringComparer.Ordinal); + + private static string[] HandlerArray(string fieldName) + => (string[])HandlerField(fieldName).GetValue(null)!; + + private static T[] HandlerTupleArray(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."); + + /// + /// 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. + /// + private static async Task> PerSessionCascadeTablesAsync() + { + var builder = new DeletionManifestBuilder(new EmptyInventoryReader(), NullLogger.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 GetSessionRowAsync(string tenantId, string sessionId, CancellationToken cancellationToken = default) + => Task.FromResult(new TableEntity(tenantId, sessionId) { ["IndexRowKey"] = IndexRowKey }); + + public Task GetSessionsIndexRowAsync(string tenantId, string indexRowKey, CancellationToken cancellationToken = default) + => Task.FromResult(new TableEntity(tenantId, indexRowKey) { ["SessionId"] = SessionId }); + + public async IAsyncEnumerable QueryAsync(string tableName, string filter, [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + await Task.CompletedTask; + yield break; + } + + public Task 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(new TableEntity(partitionKey, rowKey)); + } + return Task.FromResult(null); + } + + public Task GetActiveSessionTombstoneAsync(string tenantId, string sessionId, CancellationToken cancellationToken = default) + => Task.FromResult(null); + } +} diff --git a/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableSessionRepository.cs b/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableSessionRepository.cs index 3af1e03b..ec0e6bd7 100644 --- a/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableSessionRepository.cs +++ b/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableSessionRepository.cs @@ -33,8 +33,8 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage public Task GetSessionAsync(string tenantId, string sessionId) => _storage.GetSessionAsync(tenantId, sessionId); - public Task FindSessionTenantIdAsync(string sessionId) - => _storage.FindSessionTenantIdAsync(sessionId); + public Task ResolveSessionTenantIdAsync(string sessionId) + => _storage.ResolveSessionTenantIdAsync(sessionId); public Task> GetOpenSessionsForDeviceAsync(string tenantId, string serialNumber) => _storage.GetOpenSessionsForDeviceAsync(tenantId, serialNumber); diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Annotations/GetSessionAnnotationsFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Annotations/GetSessionAnnotationsFunction.cs index cd5c7862..ddbb4202 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Annotations/GetSessionAnnotationsFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Annotations/GetSessionAnnotationsFunction.cs @@ -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); diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Annotations/UpsertSessionAnnotationFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Annotations/UpsertSessionAnnotationFunction.cs index 0b2effc2..550c271f 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Annotations/UpsertSessionAnnotationFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Annotations/UpsertSessionAnnotationFunction.cs @@ -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( diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Metrics/GetDeviceJourneyFunctions.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Metrics/GetDeviceJourneyFunctions.cs index 10a22490..1c37eaaf 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Metrics/GetDeviceJourneyFunctions.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Metrics/GetDeviceJourneyFunctions.cs @@ -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; diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Metrics/GetTimeAttributionFunctions.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Metrics/GetTimeAttributionFunctions.cs index 6b621771..77bb274e 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Metrics/GetTimeAttributionFunctions.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Metrics/GetTimeAttributionFunctions.cs @@ -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 }); diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Raw/QueryRawEventsFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Raw/QueryRawEventsFunction.cs index 7400b3cf..87b33d1b 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Raw/QueryRawEventsFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Raw/QueryRawEventsFunction.cs @@ -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)) { diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Rules/DryRunAnalyzeRuleFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Rules/DryRunAnalyzeRuleFunction.cs index 2188b80c..79937e53 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Rules/DryRunAnalyzeRuleFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Rules/DryRunAnalyzeRuleFunction.cs @@ -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) diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Rules/GetRuleResultsFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Rules/GetRuleResultsFunction.cs index 8453bebc..0b98c10d 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Rules/GetRuleResultsFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Rules/GetRuleResultsFunction.cs @@ -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, diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Sessions/GetSessionDecisionGraphFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Sessions/GetSessionDecisionGraphFunction.cs index f2fc458f..b7590acd 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Sessions/GetSessionDecisionGraphFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Sessions/GetSessionDecisionGraphFunction.cs @@ -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); diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Sessions/GetSessionDeletePreviewFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Sessions/GetSessionDeletePreviewFunction.cs index 835afd5a..225735c5 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Sessions/GetSessionDeletePreviewFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Sessions/GetSessionDeletePreviewFunction.cs @@ -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)) diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Sessions/GetSessionEventsFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Sessions/GetSessionEventsFunction.cs index dedda5ed..1fd29e59 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Sessions/GetSessionEventsFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Sessions/GetSessionEventsFunction.cs @@ -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); } } diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Sessions/GetSessionFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Sessions/GetSessionFunction.cs index 82462eb0..621d43bf 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Sessions/GetSessionFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Sessions/GetSessionFunction.cs @@ -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) { diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Sessions/GetSessionReducerVerificationFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Sessions/GetSessionReducerVerificationFunction.cs index 8b5bdaf6..2401e08a 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Sessions/GetSessionReducerVerificationFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Sessions/GetSessionReducerVerificationFunction.cs @@ -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. diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Sessions/GetSessionSignalsFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Sessions/GetSessionSignalsFunction.cs index b3167102..218c0890 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Sessions/GetSessionSignalsFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Sessions/GetSessionSignalsFunction.cs @@ -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 { diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Sessions/RestoreSessionFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Sessions/RestoreSessionFunction.cs index 7be711e7..36c79eb2 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Sessions/RestoreSessionFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Sessions/RestoreSessionFunction.cs @@ -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)) { diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Sessions/SearchSessionsFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Sessions/SearchSessionsFunction.cs index 440de775..f4baba52 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Sessions/SearchSessionsFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Sessions/SearchSessionsFunction.cs @@ -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. }; diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Vulnerability/GetVulnerabilityReportFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Vulnerability/GetVulnerabilityReportFunction.cs index 2593832f..d3a69425 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Vulnerability/GetVulnerabilityReportFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Vulnerability/GetVulnerabilityReportFunction.cs @@ -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 diff --git a/src/Backend/AutopilotMonitor.Functions/Helpers/RecomputeTriggerGate.cs b/src/Backend/AutopilotMonitor.Functions/Helpers/RecomputeTriggerGate.cs new file mode 100644 index 00000000..69106f57 --- /dev/null +++ b/src/Backend/AutopilotMonitor.Functions/Helpers/RecomputeTriggerGate.cs @@ -0,0 +1,29 @@ +using AutopilotMonitor.Shared; + +namespace AutopilotMonitor.Functions.Helpers; + +/// +/// Gate for on-demand recompute triggers that ride on MemberRead GET routes +/// (?reanalyze=true on sessions/{id}/analysis, ?rescan=true 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). +/// +public static class RecomputeTriggerGate +{ + /// + /// True when the caller may trigger a recompute for : + /// 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. + /// + 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); + } +} diff --git a/src/Backend/AutopilotMonitor.Functions/Helpers/RequestContext.cs b/src/Backend/AutopilotMonitor.Functions/Helpers/RequestContext.cs index b546caf9..90d9af78 100644 --- a/src/Backend/AutopilotMonitor.Functions/Helpers/RequestContext.cs +++ b/src/Backend/AutopilotMonitor.Functions/Helpers/RequestContext.cs @@ -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; + /// + /// Resolves the effective tenantId for a session-scoped request (Fragilitätsaudit P5.3 — + /// the one implementation of the former 15-site copy-paste fallback). + /// + /// Non-global callers always keep (the middleware + /// already validated it; no cross-tenant reach). Global-scope callers resolve the session's + /// owning tenant via (point-read + /// with legacy-scan fallback) and get the resolved tenant when it differs; an unknown session + /// keeps so each endpoint's own not-found handling + /// (404 / empty 200 / 400) stays in charge. + /// + /// + /// + /// True on WRITE paths: gate the cross-tenant resolve on + /// instead of , so a read-only Global Reader can never + /// steer a write into a foreign tenant. + /// + public static async Task 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; + } + /// Gets the correlation ID for this request (set by CorrelationIdMiddleware). public static string GetCorrelationId(this FunctionContext context) { diff --git a/src/Backend/AutopilotMonitor.Functions/Pagination/SearchSessionsPagination.cs b/src/Backend/AutopilotMonitor.Functions/Pagination/SearchSessionsPagination.cs index 71296960..b2f56ad2 100644 --- a/src/Backend/AutopilotMonitor.Functions/Pagination/SearchSessionsPagination.cs +++ b/src/Backend/AutopilotMonitor.Functions/Pagination/SearchSessionsPagination.cs @@ -55,6 +55,7 @@ namespace AutopilotMonitor.Functions.Pagination new KeyValuePair("rebootCountMin", filter.RebootCountMin?.ToString(CultureInfo.InvariantCulture)), new KeyValuePair("rebootCountMax", filter.RebootCountMax?.ToString(CultureInfo.InvariantCulture)), new KeyValuePair("connectionType", filter.ConnectionType), + new KeyValuePair("q", filter.Q), }; if (filter.DeviceProperties != null) { diff --git a/src/Backend/AutopilotMonitor.Functions/Services/Deletion/DeletionManifestBuilder.cs b/src/Backend/AutopilotMonitor.Functions/Services/Deletion/DeletionManifestBuilder.cs index bbdefcea..edf274f3 100644 --- a/src/Backend/AutopilotMonitor.Functions/Services/Deletion/DeletionManifestBuilder.cs +++ b/src/Backend/AutopilotMonitor.Functions/Services/Deletion/DeletionManifestBuilder.cs @@ -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); diff --git a/src/Backend/AutopilotMonitor.Functions/Services/Offboarding/OffboardingFilters.cs b/src/Backend/AutopilotMonitor.Functions/Services/Offboarding/OffboardingFilters.cs index 30abd9d8..d3bf6c9a 100644 --- a/src/Backend/AutopilotMonitor.Functions/Services/Offboarding/OffboardingFilters.cs +++ b/src/Backend/AutopilotMonitor.Functions/Services/Offboarding/OffboardingFilters.cs @@ -66,5 +66,16 @@ namespace AutopilotMonitor.Functions.Services.Offboarding SecurityValidator.EnsureValidGuid(normalizedTenantId, nameof(normalizedTenantId)); return $"TenantId eq '{ODataSanitizer.EscapeValue(normalizedTenantId)}'"; } + + /// + /// RowKey eq '{tenantId}' — Variant D SafeWipe (RowKey-anchored full-table + /// filter). For tables keyed PK=<non-tenant dimension>, RK=tenantId + /// (UsageMetrics: PK=date). Full-table scan; reserve for retention-bounded tables. + /// + public static string RowKeyEquals(string normalizedTenantId) + { + SecurityValidator.EnsureValidGuid(normalizedTenantId, nameof(normalizedTenantId)); + return $"RowKey eq '{ODataSanitizer.EscapeValue(normalizedTenantId)}'"; + } } } diff --git a/src/Backend/AutopilotMonitor.Functions/Services/Offboarding/SafeWipeService.cs b/src/Backend/AutopilotMonitor.Functions/Services/Offboarding/SafeWipeService.cs index 08e585b1..177d48c7 100644 --- a/src/Backend/AutopilotMonitor.Functions/Services/Offboarding/SafeWipeService.cs +++ b/src/Backend/AutopilotMonitor.Functions/Services/Offboarding/SafeWipeService.cs @@ -118,6 +118,26 @@ namespace AutopilotMonitor.Functions.Services.Offboarding ct); } + /// + /// 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. + /// + public virtual Task 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); + } + /// /// Blob-prefix wipe. Lists blobs under {normalizedTenantId}/ in the named /// container, verifies each returned name starts with the prefix, then deletes diff --git a/src/Backend/AutopilotMonitor.Functions/Services/Offboarding/TenantOffboardingHandler.cs b/src/Backend/AutopilotMonitor.Functions/Services/Offboarding/TenantOffboardingHandler.cs index 85be5be1..782bcc81 100644 --- a/src/Backend/AutopilotMonitor.Functions/Services/Offboarding/TenantOffboardingHandler.cs +++ b/src/Backend/AutopilotMonitor.Functions/Services/Offboarding/TenantOffboardingHandler.cs @@ -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 ──────────────────────────────────────────────────────── diff --git a/src/Backend/AutopilotMonitor.Functions/Services/SessionIndexFieldManifest.cs b/src/Backend/AutopilotMonitor.Functions/Services/SessionIndexFieldManifest.cs new file mode 100644 index 00000000..f3e8bdf0 --- /dev/null +++ b/src/Backend/AutopilotMonitor.Functions/Services/SessionIndexFieldManifest.cs @@ -0,0 +1,133 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +namespace AutopilotMonitor.Functions.Services +{ + /// + /// The declared SessionsIndex column set — single source for what the FULL-MIRROR index + /// row carries (see TableStorageService.BuildSessionIndexEntity, which is pinned + /// against this manifest bidirectionally by SessionIndexFieldManifestTests). + /// + /// Maintenance flow for a NEW mirrored field: + /// 1. add it to (written with a default on every rebuild) + /// or (written only when present), + /// 2. write it in BuildSessionIndexEntity with matching semantics, + /// 3. read it in MapToSessionSummary. + /// Skipping any step turns the manifest tests red. Merge sites are guarded fail-soft: + /// MergeSessionIndexAsync logs a warning for keys outside — + /// a merged-but-not-projected field is exactly the recurring StartedAt-shift drift bug + /// (e.g. ab90423b). + /// + internal static class SessionIndexFieldManifest + { + /// + /// Written on every index rebuild with a default (never absent from an index row). + /// Note: CurrentPhaseDetail is a dead column — no Sessions writer sets it, so + /// it is always empty; kept for row-shape stability until deliberately retired. + /// + 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", + }; + + /// Written only when the Sessions row carries a value (absent otherwise). + public static readonly string[] ConditionallyProjected = + { + "CompletedAt", + "FailureReason", + "FailureSource", + "ReconcileReason", + "EspSoftFailure", + "CompletionSource", + "ValidatedBy", + "FailureSnapshotJson", + "AdminMarkedAction", + "DurationSeconds", + "DiagnosticsBlobName", + "DiagnosticsBlobDestination", + "LastEventAt", + "ResumedAt", + "StalledAt", + "AvgApiLatencyMs", + "ApiRequestCount", + "ConnectionType", + }; + + /// Every column an index row may carry (data columns; PK/RK excluded). + public static readonly string[] All = + AlwaysProjected.Concat(ConditionallyProjected).ToArray(); + + /// + /// 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. + /// + public static readonly string[] PrimaryOnly = + { + "PendingActionsJson", + "PendingActionsQueuedAt", + "DeletionState", + "PendingDeletionManifestId", + }; + + private static readonly HashSet AllSet = new(All, StringComparer.Ordinal); + + /// + /// System keys a merge entity may legitimately carry besides data columns. + /// + private static readonly HashSet SystemKeys = new(StringComparer.Ordinal) + { + "PartitionKey", "RowKey", "Timestamp", "odata.etag", + }; + + /// + /// Returns the data keys of 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). + /// + public static List FindNonManifestKeys(Azure.Data.Tables.TableEntity mergeEntity) + { + List? offenders = null; + foreach (var kvp in mergeEntity) + { + if (SystemKeys.Contains(kvp.Key) || AllSet.Contains(kvp.Key)) + continue; + (offenders ??= new List()).Add(kvp.Key); + } + return offenders ?? new List(); + } + } +} diff --git a/src/Backend/AutopilotMonitor.Functions/Services/TableStorageService.AgentApi.cs b/src/Backend/AutopilotMonitor.Functions/Services/TableStorageService.AgentApi.cs index 16990ac2..d8b519be 100644 --- a/src/Backend/AutopilotMonitor.Functions/Services/TableStorageService.AgentApi.cs +++ b/src/Backend/AutopilotMonitor.Functions/Services/TableStorageService.AgentApi.cs @@ -507,28 +507,50 @@ namespace AutopilotMonitor.Functions.Services : await SearchSessionsByScanPageAsync(tenantId, filter, pageSize, continuation); } + /// + /// Cap on the number of Azure pages one free-text (q=) 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). + /// + internal const int FreeTextBackfillMaxRounds = 10; + private async Task> 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( - 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(entities.Count); - foreach (var entity in entities) + var sessions = new List(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( + 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(sessions, nextRawToken); } @@ -688,8 +710,41 @@ namespace AutopilotMonitor.Functions.Services return string.Equals(session.ConnectionType, filter.ConnectionType, StringComparison.OrdinalIgnoreCase); } + /// + /// Free-text predicate for the dashboard search box (q=). 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. + /// + 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(); } diff --git a/src/Backend/AutopilotMonitor.Functions/Services/TableStorageService.Sessions.cs b/src/Backend/AutopilotMonitor.Functions/Services/TableStorageService.Sessions.cs index 92d934f6..9a5915fb 100644 --- a/src/Backend/AutopilotMonitor.Functions/Services/TableStorageService.Sessions.cs +++ b/src/Backend/AutopilotMonitor.Functions/Services/TableStorageService.Sessions.cs @@ -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. /// - 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 /api/raw/sessions endpoint /// (which returns every stored index column verbatim, see RawEntityProjection) 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 MergeSessionIndexAsync 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 MapToSessionSummary (read side). + /// writes. The declared column set lives in ; this + /// builder is pinned against it bidirectionally by SessionIndexFieldManifestTests and + /// must stay a SUPERSET of every field any MergeSessionIndexAsync 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 + /// MapToSessionSummary (read side — also pinned by the manifest tests). /// /// Not mirrored here: fields owned by separate write subsystems that do not touch the index — /// PendingActionsJson/PendingActionsQueuedAt (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 } } + /// RowKey of the single lookup row per session in . + private const string SessionTenantLookupRowKey = "tenant"; + /// - /// 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. /// - public async Task FindSessionTenantIdAsync(string sessionId) + public async Task ResolveSessionTenantIdAsync(string sessionId) { SecurityValidator.EnsureValidGuid(sessionId, nameof(sessionId)); + try + { + var lookupClient = _tableServiceClient.GetTableClient(Constants.TableNames.SessionTenantLookup); + var response = await lookupClient.GetEntityAsync(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; + } + + /// + /// Legacy fallback: finds the tenantId by scanning SessionsIndex cross-partition + /// (SessionId is a non-key property there). Reserve for lookup-table misses. + /// + private async Task ScanSessionsIndexForTenantIdAsync(string sessionId) + { try { var indexTableClient = _tableServiceClient.GetTableClient(Constants.TableNames.SessionsIndex); @@ -1523,6 +1584,27 @@ namespace AutopilotMonitor.Functions.Services } } + /// + /// Writes the sessionId → tenantId lookup row (fail-soft: resolution still works via + /// the scan fallback when this write is lost). + /// + 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); + } + } + /// /// 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); diff --git a/src/Shared/AutopilotMonitor.Shared/Constants.cs b/src/Shared/AutopilotMonitor.Shared/Constants.cs index a9c922bd..0e5df924 100644 --- a/src/Shared/AutopilotMonitor.Shared/Constants.cs +++ b/src/Shared/AutopilotMonitor.Shared/Constants.cs @@ -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"; + /// /// Returns all table names for initialization /// @@ -1188,6 +1198,7 @@ namespace AutopilotMonitor.Shared BackupJobs, ConfigurationBackups, SessionAnnotations, + SessionTenantLookup, }; } diff --git a/src/Shared/AutopilotMonitor.Shared/DataAccess/ISessionRepository.cs b/src/Shared/AutopilotMonitor.Shared/DataAccess/ISessionRepository.cs index 91a8fb4b..c9d4d47f 100644 --- a/src/Shared/AutopilotMonitor.Shared/DataAccess/ISessionRepository.cs +++ b/src/Shared/AutopilotMonitor.Shared/DataAccess/ISessionRepository.cs @@ -17,7 +17,13 @@ namespace AutopilotMonitor.Shared.DataAccess // --- Session CRUD --- Task StoreSessionAsync(SessionRegistration registration); Task GetSessionAsync(string tenantId, string sessionId); - Task FindSessionTenantIdAsync(string sessionId); + /// + /// Resolves the tenantId owning — 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). + /// + Task ResolveSessionTenantIdAsync(string sessionId); /// /// Returns all sessions for newest-first. /// Optional scopes to the last N days. No row cap — diff --git a/src/Shared/AutopilotMonitor.Shared/Models/SessionSearchFilter.cs b/src/Shared/AutopilotMonitor.Shared/Models/SessionSearchFilter.cs index 91ba8873..e730d547 100644 --- a/src/Shared/AutopilotMonitor.Shared/Models/SessionSearchFilter.cs +++ b/src/Shared/AutopilotMonitor.Shared/Models/SessionSearchFilter.cs @@ -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) diff --git a/src/Web/autopilot-monitor-web/app/dashboard/components/SessionTable.tsx b/src/Web/autopilot-monitor-web/app/dashboard/components/SessionTable.tsx index 630c484c..93d22bda 100644 --- a/src/Web/autopilot-monitor-web/app/dashboard/components/SessionTable.tsx +++ b/src/Web/autopilot-monitor-web/app/dashboard/components/SessionTable.tsx @@ -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({