mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-24 04:32:32 +00:00
fragility audit P5.x + P7: drift guards across SignalR names, RowKeys, config sections, scoped routing
P5.x-A: Constants.SignalRMessages catalog (13 names) + SignalRGroupHelper.HubName;
all backend send sites and web subscriptions typed against the shared-manifest
union (SignalRContext.on/off) — unknown names are tsc errors. Dead eventStream
session-payload handling removed from diagnosis page.
P5.x-B: RowKeyCodec as the single inverted-tick encoder (persisted widths pinned,
UserActivity D20 legacy); DeletionRowDump.Table on tombstone rows with
DeletionTombstoneTables.Resolve replacing the duplicated Contains('_') heuristic
(legacy fallback retained for pre-field manifests).
P7.1: PATCH config/{tenantId}/fields flipped to TenantAdminOrGA (phase 2);
Settings sections save via per-section diff PATCH from sectionFieldMap.ts —
the ~39-field stale round-trip vector is gone; legacy webhook clearing now only
on the notifications save; agent-settings reset no longer discards contact edits.
P7.2/7.3: useGlobalAdminScope is a projection over useAggregatedAdminScope
(resolveConcreteScopeView, test-pinned); scopedApi builders own the tenant/global
endpoint-pair routing for all 11 former hand-rolled call sites; scopedFetch adds
the shared JSON fetch layer.
P7.4: navVisibility extraction + roles x nav-config matrix and nav<->route-guard
consistency test with a ratchet for the two known pre-existing mismatches
(Operator and GlobalReader vs /settings — user decision pending).
P7.5: web build stamp public/version.json (prebuild, GITHUB_SHA), SWA no-store
route, deploy-web verify poll + versions/web.json manifest, Portal Build card.
P7.6: OkAsyncBaselineGuardTests freezes the 44 anonymous-response sites
(shrink-only ratchet).
Verified: dotnet build 0W/0E, Functions.Tests 4123, DecisionCore 519, V2.Core
2206, SummaryDialog 44; tsc 0, ESLint 0, vitest 762/762; mutation proofs for all
new drift nets. Docs: docs/drift-guards.md + versioning.md web row.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -16,6 +16,10 @@ on:
|
||||
- 'src/Web/autopilot-monitor-web/**'
|
||||
- '.github/workflows/deploy-web.yml'
|
||||
|
||||
env:
|
||||
PORTAL_VERSION_URL: https://portal.autopilotmonitor.com/version.json
|
||||
VERSIONS_CONTAINER: https://autopilotmonitoreu.blob.core.windows.net/versions
|
||||
|
||||
jobs:
|
||||
build_and_deploy_job:
|
||||
if: github.event_name == 'push' || github.event_name == 'workflow_dispatch' || (github.event_name == 'pull_request' && github.event.action != 'closed')
|
||||
@@ -48,6 +52,9 @@ jobs:
|
||||
NEXT_PUBLIC_API_BASE_URL: ${{ vars.NEXT_PUBLIC_API_BASE_URL }}
|
||||
NEXT_PUBLIC_PLATFORM_STATS_MANIFEST_URL: ${{ vars.NEXT_PUBLIC_PLATFORM_STATS_MANIFEST_URL }}
|
||||
NEXT_PUBLIC_APPINSIGHTS_CONNECTION_STRING: ${{ secrets.NEXT_PUBLIC_APPINSIGHTS_CONNECTION_STRING }}
|
||||
# Build stamp for public/version.json (scripts/generate-version.js in prebuild);
|
||||
# the verify step below polls the deployed file for exactly this commit.
|
||||
GITHUB_SHA: ${{ github.sha }}
|
||||
with:
|
||||
azure_static_web_apps_api_token: ${{ secrets.AZURE_SWA_DEPLOY_TOKEN }}
|
||||
repo_token: ${{ secrets.GITHUB_TOKEN }} # Used for Github integrations (i.e. PR comments)
|
||||
@@ -64,6 +71,55 @@ jobs:
|
||||
output_location: "out" # Built app content directory - optional
|
||||
###### End of Repository/Build Configurations ######
|
||||
|
||||
# The SWA action reporting success proves the upload, not that the production host
|
||||
# serves the new build — verify against the live /version.json (Cache-Control:
|
||||
# no-store via staticwebapp.config.json) before publishing the version manifest.
|
||||
# PR builds deploy to per-PR preview environments, so only main deploys are verified.
|
||||
- name: Verify deployed portal build is live
|
||||
if: env.DEPLOY_TOKEN != '' && (github.event_name == 'push' || github.event_name == 'workflow_dispatch')
|
||||
shell: pwsh
|
||||
run: |
|
||||
$expectedCommit = '${{ github.sha }}'.Substring(0, 7)
|
||||
$deadline = [DateTime]::UtcNow.AddMinutes(5)
|
||||
$attempt = 0
|
||||
|
||||
while ([DateTime]::UtcNow -lt $deadline) {
|
||||
$attempt++
|
||||
try {
|
||||
$v = Invoke-RestMethod -Uri '${{ env.PORTAL_VERSION_URL }}' -Method Get -TimeoutSec 30 -Headers @{ 'Cache-Control' = 'no-cache' }
|
||||
if ($v.commit -eq $expectedCommit) {
|
||||
Write-Host "Live after $attempt attempt(s): web @ $($v.commit), built $($v.buildUtc)"
|
||||
exit 0
|
||||
}
|
||||
Write-Host "Attempt ${attempt}: serving $($v.commit) -- waiting for $expectedCommit"
|
||||
} catch {
|
||||
Write-Host "Attempt ${attempt}: /version.json not answering yet -- $($_.Exception.Message)"
|
||||
}
|
||||
Start-Sleep -Seconds 15
|
||||
}
|
||||
|
||||
throw "Portal did not serve commit $expectedCommit within 5 minutes -- deployment not verified, version manifest NOT published"
|
||||
|
||||
# Cross-component deploy record, written last and only on a verified deploy —
|
||||
# same contract as versions/backend.json and versions/mcp.json.
|
||||
- name: Publish version manifest
|
||||
if: env.DEPLOY_TOKEN != '' && (github.event_name == 'push' || github.event_name == 'workflow_dispatch')
|
||||
shell: pwsh
|
||||
run: |
|
||||
$manifest = [ordered]@{
|
||||
component = 'web'
|
||||
commit = '${{ github.sha }}'.Substring(0, 7)
|
||||
deployedUtc = [DateTime]::UtcNow.ToString('o')
|
||||
runId = '${{ github.run_id }}'
|
||||
} | ConvertTo-Json
|
||||
|
||||
$sas = '${{ secrets.AZURE_BLOB_VERSIONS_SAS_TOKEN }}'.TrimStart('?')
|
||||
$url = "${{ env.VERSIONS_CONTAINER }}/web.json?$sas"
|
||||
# no-cache: rotating pointer, must never be served stale by any fronting cache.
|
||||
Invoke-RestMethod -Uri $url -Method Put -Headers @{ 'x-ms-blob-type' = 'BlockBlob'; 'x-ms-blob-cache-control' = 'no-cache' } -Body $manifest -ContentType 'application/json' | Out-Null
|
||||
Write-Host "Published versions/web.json"
|
||||
Write-Host $manifest
|
||||
|
||||
close_pull_request_job:
|
||||
if: github.event_name == 'pull_request' && github.event.action == 'closed'
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
@@ -154,3 +154,7 @@ docs/~$Introduction-AutopilotMonitor.pptx
|
||||
|
||||
# Playwright MCP browser artifacts (snapshots, screenshots, console logs)
|
||||
.playwright-mcp/
|
||||
|
||||
# Web build stamp: generated by scripts/generate-version.js on every build (buildUtc
|
||||
# changes each run) and served at /version.json from the static export.
|
||||
src/Web/autopilot-monitor-web/public/version.json
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
---
|
||||
type: Concept
|
||||
title: Drift Guards — SignalR Names, RowKeys, Config Sections, Scoped Routing
|
||||
description: The 2026-08 fragility-audit follow-up contracts that turn formerly hand-synchronized mirrors into enforced ones — SignalR message-name catalog, the inverted-tick RowKey codec, tombstone table tagging, the OkAsync ratchet, per-section config PATCH, and the web's scoped-endpoint builders.
|
||||
resource: /src/Backend/AutopilotMonitor.Functions.Tests/OkAsyncBaselineGuardTests.cs
|
||||
tags:
|
||||
- contracts
|
||||
- guard-tests
|
||||
- signalr
|
||||
- web
|
||||
timestamp: 2026-08-13T00:00:00+02:00
|
||||
---
|
||||
|
||||
# Drift Guards
|
||||
|
||||
Follow-up to the 2026-08 fragility audit (see [Lifecycle Manifests & Session Scope
|
||||
Resolution](backend/lifecycle-manifests-and-session-scope.md) for the earlier rounds).
|
||||
Each section below replaces a hand-synchronized mirror with an enforced contract: a
|
||||
change on one side now fails a build, a type check, or a test on the other side.
|
||||
|
||||
# Schema
|
||||
|
||||
## SignalR message names — one catalog, typed on both ends
|
||||
|
||||
`Constants.SignalRMessages` (Shared) is the complete catalog of SignalR target names.
|
||||
Backend senders (output bindings and the imperative `SignalRNotificationService`)
|
||||
reference the constants; the hub name is `SignalRGroupHelper.HubName`. The catalog is
|
||||
exported into `shared-manifests.json` (`signalRMessages` section) by
|
||||
`SharedManifestParityTests`, and the web's `SignalRContext.on/off` type their event
|
||||
name against the generated union (`lib/signalrMessages.ts`) — subscribing to a name
|
||||
the backend never sends is a tsc error, not a silent no-op. `newevents` is the one
|
||||
legacy lowercase name; it is a persisted wire name, do not "fix" the casing.
|
||||
|
||||
## Inverted-tick RowKeys — one codec
|
||||
|
||||
`RowKeyCodec` (Functions/Helpers) is the single encoder/decoder for
|
||||
`rev(t) = MaxTicks - t.Ticks` RowKeys. Every writer routes through it; the persisted
|
||||
per-table shapes (D19 standard, AuditLogs `!`-prefix, UserActivity's legacy D20 width,
|
||||
ConfigurationBackups' 28-char truncation) are pinned by `RowKeyCodecTests` and must
|
||||
never change — a width/prefix change reorders live tables against their existing rows.
|
||||
`BusinessTimestamp` keeps the OData clause builders and delegates decode to the codec.
|
||||
|
||||
## Tombstone rows carry their table
|
||||
|
||||
`DeletionRowDump.Table` names the source table on FINAL (Tombstone) rows — the one
|
||||
step that mixes two tables while `DeletionStep.Table` is null.
|
||||
`DeletionTombstoneTables.Resolve` is the only consumer-side resolution (delete +
|
||||
restore + dry-run); the historical `Contains('_')` RowKey-shape heuristic survives
|
||||
solely as the fallback for manifests written before the field existed and must outlive
|
||||
one full manifest-retention cycle.
|
||||
|
||||
## Anonymous OkAsync responses — frozen, shrink-only
|
||||
|
||||
`OkAsyncBaselineGuardTests` freezes the 44 existing `req.OkAsync(new { … })` call
|
||||
sites as a per-file baseline. A new anonymous response (new file or count above
|
||||
baseline) fails; converting a site to a typed DTO (Shared response models, picked up
|
||||
by the manifest parity tests) fails too until its baseline entry is lowered — the debt
|
||||
list stays truthful and only ever shrinks.
|
||||
|
||||
## Tenant settings — per-section PATCH
|
||||
|
||||
The web's Settings sections no longer PUT the full ~92-field configuration. Each
|
||||
section's exact write surface lives in `app/settings/sectionFieldMap.ts` (owned fields
|
||||
plus documented `alsoWrites` write-throughs); `saveConfiguration` diffs those fields
|
||||
against the loaded config and PATCHes only the changes through
|
||||
`PATCH config/{tenantId}/fields` — the transactional endpoint (CAS + fail-closed
|
||||
backup + exactly-these-fields verify + auto-rollback) that previously served MCP only.
|
||||
The policy line is TenantAdminOrGA; a tenant admin runs on the stricter
|
||||
`TenantConfigCallerTier.TenantAdmin` whose deny-list turns GA-only fields into
|
||||
explicit 400s. Deploy order: backend before web. `sectionFieldMap.test.ts` pins
|
||||
field↔section ownership (each field exactly one owner, all fields on the model, none
|
||||
server-denied); backups/revert stay GlobalAdminOnly.
|
||||
|
||||
## Scoped endpoint routing — one decision object
|
||||
|
||||
`lib/scopedApi.ts` owns the tenant/global endpoint-pair choice: pages pass their scope
|
||||
hook object (`routeGlobal`, `selectedTenantId`, `effectiveTenantId`) instead of
|
||||
hand-rolling `routeGlobal ? global(..., sel || undefined) : tenant(tid, ...)` with the
|
||||
tenant parameter at a different position per pair. `useGlobalAdminScope` is now a thin
|
||||
projection over `useAggregatedAdminScope` (one hook, aggregated as a mode) via the
|
||||
pure `resolveConcreteScopeView`. `lib/scopedFetch.ts` adds the shared JSON+ok-check
|
||||
fetch layer. `lib/navVisibility.ts` extracts the sidebar's roles × nav-config × guard
|
||||
logic; its matrix test also ratchets the known "sidebar shows it, guard bounces it"
|
||||
mismatches (plain Operator and GlobalReader vs. `/settings` — pending a user decision).
|
||||
|
||||
# Examples
|
||||
|
||||
Renaming a SignalR message end to end: change the constant in
|
||||
`Constants.SignalRMessages` → `AM_WRITE_SHARED_MANIFESTS=1 dotnet test --filter
|
||||
SharedManifestParityTests` → `node scripts/generate-shared-manifest-types.js` → tsc
|
||||
now flags every stale web subscription literal.
|
||||
|
||||
Adding a field to a Settings section: add it to the section in `sectionFieldMap.ts`
|
||||
(tsc validates the name against the manifest), wire the form state into the
|
||||
`updatedConfig` construction in `TenantConfigContext`, and extend the expected-fields
|
||||
list in `sectionFieldMap.test.ts`.
|
||||
|
||||
# Citations
|
||||
|
||||
- `src/Shared/AutopilotMonitor.Shared/Constants.cs` — SignalRMessages catalog
|
||||
- `src/Backend/AutopilotMonitor.Functions/Helpers/RowKeyCodec.cs` + `RowKeyCodecTests.cs`
|
||||
- `src/Shared/AutopilotMonitor.Shared/Models/Deletion/DeletionManifest.cs` — `DeletionRowDump.Table`, `DeletionTombstoneTables`
|
||||
- `src/Backend/AutopilotMonitor.Functions.Tests/OkAsyncBaselineGuardTests.cs`
|
||||
- `src/Web/autopilot-monitor-web/app/settings/sectionFieldMap.ts` + `__tests__/sectionFieldMap.test.ts`
|
||||
- `src/Web/autopilot-monitor-web/lib/scopedApi.ts`, `lib/navVisibility.ts`, `hooks/concreteAdminScopeView.ts`
|
||||
- [Version Contract](versioning.md) — the web `/version.json` stamp + deploy verify added in the same round
|
||||
@@ -14,6 +14,7 @@ https://docs.autopilotmonitor.com (separate repository).
|
||||
* [Architecture Guide](architecture.md) - High-level architecture overview and solution layout for contributors.
|
||||
* [Version Contract](versioning.md) - One version shape for agent, backend and MCP: the ETag-CAS counter blob that mints build numbers, and the manifests published only after a deploy is verified live.
|
||||
* [URL Registry](url-registry.md) - Every well-known host lives in one registry file per component (Constants.cs / utils/config.ts / src/config.ts), enforced by guard tests; includes the agent-download migration to the download alias and its deploy-order constraint.
|
||||
* [Drift Guards](drift-guards.md) - The 2026-08 fragility-audit follow-up contracts: SignalR message-name catalog (typed on both ends), the inverted-tick RowKey codec, tombstone table tagging, the shrink-only OkAsync baseline, per-section config PATCH with the section field map, and the web's scoped-endpoint builders + sidebar visibility matrix.
|
||||
|
||||
# Agent
|
||||
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
# Log
|
||||
|
||||
## 2026-08-13 (late evening)
|
||||
|
||||
* **Creation**: Added `drift-guards.md` — the fragility-audit follow-up contracts (P5.x + P7): SignalR message-name catalog in `Constants.SignalRMessages`, exported through the shared manifest and typed into the web's `SignalRContext.on/off` union; the `RowKeyCodec` single encoder for inverted-tick RowKeys (persisted widths pinned, incl. UserActivity's legacy D20); `DeletionRowDump.Table` on tombstone rows with `DeletionTombstoneTables.Resolve` replacing the duplicated `Contains('_')` heuristic (legacy fallback retained one retention cycle); the shrink-only `OkAsyncBaselineGuardTests` per-file baseline (44 sites frozen); the Settings per-section PATCH (section field map + diff-based `config/{tenantId}/fields` writes on the TenantAdmin caller tier); and the web's `scopedApi` endpoint-pair builders, single admin-scope hook, `navVisibility` matrix and guard-consistency ratchet.
|
||||
|
||||
* **Update**: `versioning.md` — the web portal joins the version contract commit-only: `public/version.json` generated at build time from `GITHUB_SHA`, served with `Cache-Control: no-store`, polled by `deploy-web.yml` until the deployed commit matches before `versions/web.json` is published.
|
||||
|
||||
## 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.
|
||||
|
||||
+16
-6
@@ -1,7 +1,7 @@
|
||||
---
|
||||
type: Concept
|
||||
title: Version Contract — Agent, Backend, MCP
|
||||
description: One version shape for all three shipping components, the ETag-CAS counter blob that mints build numbers, and the published manifests that make "what is deployed" checkable from outside.
|
||||
title: Version Contract — Agent, Backend, MCP, Web
|
||||
description: One version shape for all shipping components, the ETag-CAS counter blob that mints build numbers, and the published manifests that make "what is deployed" checkable from outside.
|
||||
resource: /.github/scripts/Request-BuildNumber.ps1
|
||||
tags:
|
||||
- build
|
||||
@@ -13,9 +13,12 @@ timestamp: 2026-07-22T00:00:00+02:00
|
||||
|
||||
# Version Contract
|
||||
|
||||
Three components ship from this repository — the agent, the Functions backend and the
|
||||
MCP server. They share one version shape, one way of minting build numbers, and one
|
||||
manifest format, so that a version string means the same thing whoever emits it.
|
||||
Four components ship from this repository — the agent, the Functions backend, the
|
||||
MCP server and the web portal. They share one version shape, one way of minting build
|
||||
numbers, and one manifest format, so that a version string means the same thing
|
||||
whoever emits it. The web portal is the commit-only exception: a static export has no
|
||||
runtime that could report a `major.minor.build`, so it carries `commit` + `buildUtc`
|
||||
alone (no counter blob).
|
||||
|
||||
```
|
||||
version = <major>.<minor>.<build> major.minor curated by hand, build minted per build
|
||||
@@ -28,6 +31,7 @@ buildUtc = ISO-8601 build moment
|
||||
| Agent V2 | `VersionPrefix` in `AutopilotMonitor.Agent.V2.csproj` | `agent/buildcounter-v2.txt` | `agent/version.json` |
|
||||
| Backend | `VersionPrefix` in `AutopilotMonitor.Functions.csproj` | `versions/backend.txt` | `GET /api/health` |
|
||||
| MCP | `version` in `package.json` | `versions/mcp.txt` | `GET /health`, MCP handshake |
|
||||
| Web portal | — (commit-only) | — | `GET /version.json` on the portal host |
|
||||
|
||||
# Schema
|
||||
|
||||
@@ -77,7 +81,12 @@ exception: `build.ps1` produces uploadable artifacts and reserves a real number
|
||||
```
|
||||
|
||||
The MCP manifest adds `docsCommit`, because for that component "which build" and "which
|
||||
documentation" are separate questions.
|
||||
documentation" are separate questions. `versions/web.json` carries `component`, `commit`,
|
||||
`deployedUtc` and `runId` (no `version`/`buildUtc` — the served `/version.json` holds the
|
||||
build moment). The portal's own stamp is generated into `public/version.json` by
|
||||
`scripts/generate-version.js` (prebuild) from `GITHUB_SHA`, served with
|
||||
`Cache-Control: no-store`, and `deploy-web.yml` polls it until the deployed commit
|
||||
matches the pushed SHA before publishing the manifest.
|
||||
|
||||
Each is written **last and only after the deploy workflow has polled the component's
|
||||
health endpoint until it reports that exact version and commit**. A green deploy step
|
||||
@@ -104,5 +113,6 @@ remain, being the customer-facing integrity contract.
|
||||
|
||||
- `.github/scripts/Request-BuildNumber.ps1` — the CAS protocol
|
||||
- `.github/workflows/deploy-backend.yml`, `deploy-mcp.yml` — reserve, verify, publish
|
||||
- `.github/workflows/deploy-web.yml`, `src/Web/autopilot-monitor-web/scripts/generate-version.js` — web stamp, verify, publish
|
||||
- `.github/workflows/build-agent.yml`, `scripts/Deployment/build.ps1` — agent line
|
||||
- [Build Counter Blob](agent/build-counter-blob.md) — agent-specific consequences
|
||||
|
||||
@@ -293,6 +293,11 @@ public class DeletionManifestBuilderTests
|
||||
Assert.NotEqual(SessionId, tombstone.Rows[0].Rk); // SessionsIndex first
|
||||
Assert.Contains(SessionId, tombstone.Rows[0].Rk, StringComparison.Ordinal); // RK still references the session
|
||||
Assert.Equal(SessionId, tombstone.Rows[1].Rk); // Sessions row second
|
||||
|
||||
// FINAL rows carry their source table explicitly (DeletionStep.Table is null on this
|
||||
// step); delete/restore route by it instead of the legacy RowKey-shape heuristic.
|
||||
Assert.Equal(Constants.TableNames.SessionsIndex, tombstone.Rows[0].Table);
|
||||
Assert.Equal(Constants.TableNames.Sessions, tombstone.Rows[1].Table);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
using System.Text.Json;
|
||||
using AutopilotMonitor.Shared;
|
||||
using AutopilotMonitor.Shared.Models.Deletion;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Pins the FINAL-step table resolution: an explicit <see cref="DeletionRowDump.Table"/> wins
|
||||
/// unconditionally; only legacy manifests (written before the field existed) fall back to the
|
||||
/// RowKey-shape heuristic. The adversarial rows deliberately contradict the heuristic so a
|
||||
/// regression to Contains('_') routing flips the assertion.
|
||||
/// </summary>
|
||||
public class DeletionTombstoneTablesTests
|
||||
{
|
||||
[Fact]
|
||||
public void Explicit_table_wins_over_contradicting_rowkey_shape()
|
||||
{
|
||||
// RK contains '_' (heuristic would say SessionsIndex) but Table says Sessions — and vice versa.
|
||||
var sessionsRow = new DeletionRowDump { Pk = "t", Rk = "id_with_underscores", Table = Constants.TableNames.Sessions };
|
||||
var indexRow = new DeletionRowDump { Pk = "t", Rk = "plainkey", Table = Constants.TableNames.SessionsIndex };
|
||||
|
||||
Assert.Equal(Constants.TableNames.Sessions, DeletionTombstoneTables.Resolve(sessionsRow));
|
||||
Assert.Equal(Constants.TableNames.SessionsIndex, DeletionTombstoneTables.Resolve(indexRow));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Legacy_rows_without_table_fall_back_to_rowkey_shape()
|
||||
{
|
||||
// Pre-field manifests: Sessions RK is the bare session GUID, index RK is composed.
|
||||
var legacySessions = new DeletionRowDump { Pk = "t", Rk = "22222222-2222-2222-2222-222222222222" };
|
||||
var legacyIndex = new DeletionRowDump { Pk = "t", Rk = "6299999999999999999_22222222-2222-2222-2222-222222222222" };
|
||||
|
||||
Assert.Equal(Constants.TableNames.Sessions, DeletionTombstoneTables.Resolve(legacySessions));
|
||||
Assert.Equal(Constants.TableNames.SessionsIndex, DeletionTombstoneTables.Resolve(legacyIndex));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Table_field_round_trips_through_manifest_json()
|
||||
{
|
||||
var dump = new DeletionRowDump { Pk = "t", Rk = "rk", Table = Constants.TableNames.SessionsIndex };
|
||||
var json = JsonSerializer.Serialize(dump, DeletionManifestJson.SerializerOptions);
|
||||
Assert.Contains("\"table\":", json);
|
||||
|
||||
var roundTripped = JsonSerializer.Deserialize<DeletionRowDump>(json, DeletionManifestJson.SerializerOptions);
|
||||
Assert.Equal(Constants.TableNames.SessionsIndex, roundTripped!.Table);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Legacy_manifest_json_without_table_deserializes_to_null_and_is_omitted_on_write()
|
||||
{
|
||||
// WhenWritingNull keeps rows of table-targeted steps (Table always null there) byte-
|
||||
// identical to the pre-field format — SchemaHash of those steps is unaffected.
|
||||
var legacyJson = "{\"pk\":\"t\",\"rk\":\"rk\",\"props\":{}}";
|
||||
var dump = JsonSerializer.Deserialize<DeletionRowDump>(legacyJson, DeletionManifestJson.SerializerOptions);
|
||||
Assert.Null(dump!.Table);
|
||||
|
||||
var rewritten = JsonSerializer.Serialize(dump, DeletionManifestJson.SerializerOptions);
|
||||
Assert.DoesNotContain("table", rewritten);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text.RegularExpressions;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Ratchet against NEW anonymous-object API responses: <c>req.OkAsync(new { ... })</c> ships a
|
||||
/// shape no type checks, no manifest exports, and the web mirrors by hand — the drift class the
|
||||
/// 2026-08-13 fragility audit found across 44 call sites. New/changed endpoints return typed
|
||||
/// response DTOs (AutopilotMonitor.Shared.Models, picked up by SharedManifestParityTests);
|
||||
/// the existing sites are frozen as a per-file baseline that may only SHRINK:
|
||||
/// - a new anonymous OkAsync (new file, or count above baseline) fails,
|
||||
/// - converting a site to a typed DTO fails too until its baseline entry is lowered/removed,
|
||||
/// so the baseline always reflects reality and the debt is visible in the diff.
|
||||
/// Typed object initializers (<c>OkAsync(new SomeResponse { ... })</c>) do not match.
|
||||
/// </summary>
|
||||
public class OkAsyncBaselineGuardTests
|
||||
{
|
||||
private static readonly Regex AnonymousOkAsync = new(@"OkAsync\(\s*new\s*\{", RegexOptions.Compiled);
|
||||
|
||||
/// <summary>
|
||||
/// Frozen 2026-08-13 baseline (44 sites / 34 files), relative to the Functions project.
|
||||
/// Only ever lower a count or delete an entry — never raise or add.
|
||||
/// </summary>
|
||||
private static readonly Dictionary<string, int> Baseline = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["Functions/Admin/GetActiveUsersFunction.cs"] = 1,
|
||||
["Functions/Admin/GetAuditLogsFunction.cs"] = 2,
|
||||
["Functions/Admin/GetGlobalAuditLogsFunction.cs"] = 2,
|
||||
["Functions/Admin/GetOpsEventsFunction.cs"] = 2,
|
||||
["Functions/Annotations/GetSessionAnnotationsFunction.cs"] = 1,
|
||||
["Functions/Annotations/ListSessionAnnotationsFunction.cs"] = 1,
|
||||
["Functions/Annotations/ListTenantSessionAnnotationsFunction.cs"] = 1,
|
||||
["Functions/Annotations/UpsertSessionAnnotationFunction.cs"] = 2,
|
||||
["Functions/Config/GetAllTenantConfigurationsFunction.cs"] = 2,
|
||||
["Functions/Graph/GetGraphPermissionsStatusFunction.cs"] = 1,
|
||||
["Functions/Graph/GetScriptDisplayNamesFunction.cs"] = 3,
|
||||
["Functions/Graph/RefreshGraphPermissionsFunction.cs"] = 1,
|
||||
["Functions/Raw/QueryRawEventsFunction.cs"] = 2,
|
||||
["Functions/Raw/QueryRawSessionsFunction.cs"] = 1,
|
||||
["Functions/Reports/GetDeviceNotRegisteredFunction.cs"] = 1,
|
||||
["Functions/Reports/GetHardwareRejectedFunction.cs"] = 1,
|
||||
["Functions/Reports/GetSessionReportsFunction.cs"] = 2,
|
||||
["Functions/Reports/GetTpmPssUnsupportedFunction.cs"] = 1,
|
||||
["Functions/Sessions/GetAllSessionStatsFunction.cs"] = 1,
|
||||
["Functions/Sessions/GetAllSessionsFunction.cs"] = 1,
|
||||
["Functions/Sessions/GetSessionDecisionGraphFunction.cs"] = 1,
|
||||
["Functions/Sessions/GetSessionDeletionsListFunction.cs"] = 1,
|
||||
["Functions/Sessions/GetSessionEventsFunction.cs"] = 2,
|
||||
["Functions/Sessions/GetSessionFunction.cs"] = 1,
|
||||
["Functions/Sessions/GetSessionReducerVerificationFunction.cs"] = 1,
|
||||
["Functions/Sessions/GetSessionSignalsFunction.cs"] = 1,
|
||||
["Functions/Sessions/GetSessionStatsFunction.cs"] = 1,
|
||||
["Functions/Sessions/GetSessionsFunction.cs"] = 1,
|
||||
["Functions/Sessions/GetTenantDeletionManifestsFunction.cs"] = 1,
|
||||
["Functions/Sessions/GetTenantsWithDeletionManifestsFunction.cs"] = 1,
|
||||
["Functions/Sessions/QuickSearchSessionsFunction.cs"] = 1,
|
||||
["Functions/Sessions/SearchSessionsByCveFunction.cs"] = 1,
|
||||
["Functions/Sessions/SearchSessionsByEventFunction.cs"] = 1,
|
||||
["Functions/Sessions/SearchSessionsFunction.cs"] = 1,
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public void Anonymous_OkAsync_sites_never_exceed_the_frozen_baseline()
|
||||
{
|
||||
var functionsRoot = Path.Combine(FindRepoRoot(), "src", "Backend", "AutopilotMonitor.Functions");
|
||||
var actual = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
foreach (var file in Directory.EnumerateFiles(functionsRoot, "*.cs", SearchOption.AllDirectories))
|
||||
{
|
||||
var relative = Path.GetRelativePath(functionsRoot, file).Replace(Path.DirectorySeparatorChar, '/');
|
||||
if (relative.StartsWith("bin/", StringComparison.OrdinalIgnoreCase) ||
|
||||
relative.StartsWith("obj/", StringComparison.OrdinalIgnoreCase) ||
|
||||
relative.Contains("/bin/", StringComparison.OrdinalIgnoreCase) ||
|
||||
relative.Contains("/obj/", StringComparison.OrdinalIgnoreCase))
|
||||
continue;
|
||||
|
||||
var count = AnonymousOkAsync.Matches(File.ReadAllText(file)).Count;
|
||||
if (count > 0)
|
||||
actual[relative] = count;
|
||||
}
|
||||
|
||||
var violations = new List<string>();
|
||||
|
||||
foreach (var (file, count) in actual.OrderBy(kv => kv.Key, StringComparer.Ordinal))
|
||||
{
|
||||
if (!Baseline.TryGetValue(file, out var allowed))
|
||||
violations.Add($"{file}: {count} NEW anonymous OkAsync site(s) — return a typed response DTO instead.");
|
||||
else if (count > allowed)
|
||||
violations.Add($"{file}: {count} anonymous OkAsync site(s), baseline allows {allowed} — return a typed response DTO for the new one(s).");
|
||||
}
|
||||
|
||||
// Ratchet: a converted site must also lower its baseline entry, so the frozen debt
|
||||
// list stays truthful (and its shrink is visible in the diff).
|
||||
foreach (var (file, allowed) in Baseline.OrderBy(kv => kv.Key, StringComparer.Ordinal))
|
||||
{
|
||||
actual.TryGetValue(file, out var count);
|
||||
if (count < allowed)
|
||||
violations.Add($"{file}: baseline says {allowed} but only {count} remain — lower/remove its entry in {nameof(OkAsyncBaselineGuardTests)} (ratchet down).");
|
||||
}
|
||||
|
||||
Assert.True(violations.Count == 0,
|
||||
"Anonymous OkAsync(new { ... }) baseline violated:\n " + string.Join("\n ", violations));
|
||||
}
|
||||
|
||||
private static string FindRepoRoot()
|
||||
{
|
||||
var dir = new DirectoryInfo(AppContext.BaseDirectory);
|
||||
while (dir != null && !File.Exists(Path.Combine(dir.FullName, "AutopilotMonitor.sln")))
|
||||
{
|
||||
dir = dir.Parent;
|
||||
}
|
||||
Assert.NotNull(dir);
|
||||
return dir!.FullName;
|
||||
}
|
||||
}
|
||||
@@ -605,7 +605,7 @@ public class PolicyEnforcementMiddlewareTests
|
||||
Assert.True(result.Allowed);
|
||||
}
|
||||
|
||||
// ── Transactional config patch / backups / revert — GlobalAdminOnly (phase 1) ──
|
||||
// ── Transactional config patch (TenantAdminOrGA) / backups + revert (GlobalAdminOnly) ──
|
||||
|
||||
[Fact]
|
||||
public async Task ConfigFieldPatchRoutes_GlobalAdmin_IsAllowed()
|
||||
@@ -658,13 +658,12 @@ public class PolicyEnforcementMiddlewareTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ConfigFieldPatchRoutes_TenantAdmin_GlobalReader_Delegated_AreForbidden()
|
||||
public async Task ConfigBackupAndRevertRoutes_TenantAdmin_GlobalReader_Delegated_AreForbidden()
|
||||
{
|
||||
// Phase 1 is deliberately GA-only — even the tenant's OWN admin is denied until the
|
||||
// phase-2 flip to TenantAdminOrGA (+ the caller-tier field deny-list in the service).
|
||||
// Backups + revert stay deliberately GA-only (operator restore surface) — even the
|
||||
// tenant's OWN admin is denied. Only the PATCH line was widened in phase 2.
|
||||
foreach (var (method, path) in new[]
|
||||
{
|
||||
("PATCH", $"/api/config/{TenantA}/fields"),
|
||||
("GET", $"/api/config/{TenantA}/backups"),
|
||||
("POST", $"/api/config/{TenantA}/revert"),
|
||||
})
|
||||
@@ -672,7 +671,7 @@ public class PolicyEnforcementMiddlewareTests
|
||||
var ownAdmin = BuildHarness();
|
||||
ownAdmin.AsTenantAdmin(TenantA, "admin@contoso.com");
|
||||
var adminResult = await ownAdmin.Middleware.DecideAsync(method, path, null, AuthedPrincipal(TenantA, "admin@contoso.com"));
|
||||
Assert.False(adminResult.Allowed, $"{method} {path} must deny a tenant admin in phase 1");
|
||||
Assert.False(adminResult.Allowed, $"{method} {path} must deny a tenant admin");
|
||||
|
||||
var reader = BuildHarness();
|
||||
reader.AsGlobalRole(Constants.GlobalRoles.GlobalReader);
|
||||
@@ -682,10 +681,49 @@ public class PolicyEnforcementMiddlewareTests
|
||||
var delegated = BuildHarness();
|
||||
delegated.AsDelegated(TenantA, Constants.DelegatedRoles.DelegatedAdmin);
|
||||
var delegatedResult = await delegated.Middleware.DecideAsync(method, path, null, AuthedPrincipal(TenantB, "msp@partner.example"));
|
||||
Assert.False(delegatedResult.Allowed, $"{method} {path} must deny a delegated (MSP) admin in phase 1");
|
||||
Assert.False(delegatedResult.Allowed, $"{method} {path} must deny a delegated (MSP) admin");
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ConfigFieldPatch_OwnTenantAdmin_IsAllowed()
|
||||
{
|
||||
// Phase 2: the web's per-section save PATCHes the caller's own config row. The
|
||||
// GA-only field protection moved into the service's TenantAdmin caller tier
|
||||
// (explicit 400 per field) — the route itself admits the own-tenant admin.
|
||||
var h = BuildHarness();
|
||||
h.AsTenantAdmin(TenantA, "admin@contoso.com");
|
||||
|
||||
var result = await h.Middleware.DecideAsync(
|
||||
"PATCH", $"/api/config/{TenantA}/fields", null, AuthedPrincipal(TenantA, "admin@contoso.com"));
|
||||
|
||||
Assert.True(result.Allowed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ConfigFieldPatch_ForeignTenant_Reader_Delegated_AreForbidden()
|
||||
{
|
||||
// RouteParam scoping: a tenant admin cannot patch ANOTHER tenant's row; the read-only
|
||||
// Global Reader and delegated (MSP) admins stay excluded entirely.
|
||||
var foreignAdmin = BuildHarness();
|
||||
foreignAdmin.AsTenantAdmin(TenantA, "admin@contoso.com");
|
||||
var foreignResult = await foreignAdmin.Middleware.DecideAsync(
|
||||
"PATCH", $"/api/config/{TenantB}/fields", null, AuthedPrincipal(TenantA, "admin@contoso.com"));
|
||||
Assert.False(foreignResult.Allowed, "own-tenant admin must not patch a foreign tenant's config");
|
||||
|
||||
var reader = BuildHarness();
|
||||
reader.AsGlobalRole(Constants.GlobalRoles.GlobalReader);
|
||||
var readerResult = await reader.Middleware.DecideAsync(
|
||||
"PATCH", $"/api/config/{TenantA}/fields", null, AuthedPrincipal(TenantB, "reader@vendor.example"));
|
||||
Assert.False(readerResult.Allowed, "read-only Global Reader must not patch config fields");
|
||||
|
||||
var delegated = BuildHarness();
|
||||
delegated.AsDelegated(TenantA, Constants.DelegatedRoles.DelegatedAdmin);
|
||||
var delegatedResult = await delegated.Middleware.DecideAsync(
|
||||
"PATCH", $"/api/config/{TenantA}/fields", null, AuthedPrincipal(TenantB, "msp@partner.example"));
|
||||
Assert.False(delegatedResult.Allowed, "delegated (MSP) admin must not patch config fields");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Delegated_OwnHomeTenant_NonMember_IsStillForbidden()
|
||||
{
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
using System;
|
||||
using AutopilotMonitor.Functions.Helpers;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Pins the single inverted-tick RowKey encoder every table-key builder now routes through.
|
||||
/// The encodings are PERSISTED — a change here reorders live tables against their existing
|
||||
/// rows, so the exact widths and boundary literals are asserted, not just round-trips.
|
||||
/// </summary>
|
||||
public class RowKeyCodecTests
|
||||
{
|
||||
[Fact]
|
||||
public void InvertedTicks_boundary_literals_are_pinned()
|
||||
{
|
||||
// rev(MaxValue) = 0 and rev(MinValue) = MaxValue.Ticks — exact strings, no derivation.
|
||||
Assert.Equal("0000000000000000000", RowKeyCodec.InvertedTicks(DateTime.MaxValue));
|
||||
Assert.Equal("3155378975999999999", RowKeyCodec.InvertedTicks(DateTime.MinValue));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void InvertedTicks_is_fixed_width_19_digits()
|
||||
{
|
||||
foreach (var utc in SampleInstants())
|
||||
{
|
||||
var key = RowKeyCodec.InvertedTicks(utc);
|
||||
Assert.Equal(19, key.Length);
|
||||
Assert.All(key, c => Assert.InRange(c, '0', '9'));
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void InvertedTicksD20_is_the_same_value_one_zero_wider()
|
||||
{
|
||||
// UserActivity legacy width: identical rev value, padded to 20 digits. Existing D20
|
||||
// rows pin this — a D19 key would sort after every D20 key in that table.
|
||||
foreach (var utc in SampleInstants())
|
||||
{
|
||||
var d20 = RowKeyCodec.InvertedTicksD20(utc);
|
||||
Assert.Equal(20, d20.Length);
|
||||
Assert.Equal("0" + RowKeyCodec.InvertedTicks(utc), d20);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Later_instants_sort_lexicographically_first()
|
||||
{
|
||||
var earlier = new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc);
|
||||
var later = earlier.AddSeconds(1);
|
||||
Assert.True(string.CompareOrdinal(RowKeyCodec.InvertedTicks(later), RowKeyCodec.InvertedTicks(earlier)) < 0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Decode_round_trips_encode_exactly()
|
||||
{
|
||||
foreach (var utc in SampleInstants())
|
||||
{
|
||||
Assert.True(RowKeyCodec.TryDecodeInvertedTicks(RowKeyCodec.InvertedTicks(utc), out var decoded));
|
||||
Assert.Equal(utc.Ticks, decoded.Ticks);
|
||||
Assert.Equal(DateTimeKind.Utc, decoded.Kind);
|
||||
}
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("")] // empty
|
||||
[InlineData("123456789012345678x")] // non-digit
|
||||
[InlineData("!155378975999999999")] // prefix leaked into the digit run
|
||||
[InlineData("9999999999999999999")] // rev > MaxValue.Ticks → negative tick value
|
||||
public void Decode_rejects_foreign_input(string digits)
|
||||
{
|
||||
Assert.False(RowKeyCodec.TryDecodeInvertedTicks(digits, out _));
|
||||
}
|
||||
|
||||
private static DateTime[] SampleInstants() => new[]
|
||||
{
|
||||
new DateTime(2026, 8, 13, 18, 30, 12, DateTimeKind.Utc),
|
||||
new DateTime(2001, 2, 3, 4, 5, 6, DateTimeKind.Utc).AddTicks(7),
|
||||
DateTime.MinValue,
|
||||
DateTime.MaxValue,
|
||||
};
|
||||
}
|
||||
@@ -92,6 +92,36 @@ public class SessionRestoreServiceTests
|
||||
Assert.Equal(3, result.InventoryReIncrements);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Restore_routes_tombstone_rows_by_explicit_table_not_rowkey_shape()
|
||||
{
|
||||
var harness = new Harness();
|
||||
harness.SetCompletedCascade();
|
||||
// Adversarial fixture: each row's explicit Table CONTRADICTS the legacy RowKey-shape
|
||||
// heuristic (Sessions RK carries '_', SessionsIndex RK is bare). New manifests always
|
||||
// carry Table — a call-site regression back to Contains('_') routing flips both
|
||||
// tables and this test goes red. (BuildFullManifest's tombstone rows deliberately
|
||||
// stay Table-less to keep exercising the legacy fallback in the other facts.)
|
||||
var tombstone = harness.Manifest.Steps.First(s => s.Class == DeletionStepClass.Final);
|
||||
tombstone.Rows = new List<DeletionRowDump>
|
||||
{
|
||||
new DeletionRowDump { Pk = TenantId, Rk = "bare-index-key", Table = Constants.TableNames.SessionsIndex },
|
||||
new DeletionRowDump { Pk = TenantId, Rk = $"under_scored_{SessionId}", Table = Constants.TableNames.Sessions },
|
||||
};
|
||||
|
||||
var result = await harness.Sut.RestoreAsync(TenantId, SessionId, ManifestId, dryRun: false, actor: "ga@example.com");
|
||||
|
||||
Assert.Equal(SessionRestoreOutcome.Restored, result.Outcome);
|
||||
harness.Storage.Verify(s => s.RestoreRowsByExactKeysInBatchesAsync(
|
||||
Constants.TableNames.SessionsIndex,
|
||||
It.Is<IReadOnlyList<DeletionRowDump>>(rows => rows.Count == 1 && rows[0].Rk == "bare-index-key"),
|
||||
RestoreMode.Full, It.IsAny<CancellationToken>()), Times.Once);
|
||||
harness.Storage.Verify(s => s.RestoreRowsByExactKeysInBatchesAsync(
|
||||
Constants.TableNames.Sessions,
|
||||
It.Is<IReadOnlyList<DeletionRowDump>>(rows => rows.Count == 1 && rows[0].Rk.StartsWith("under_scored_")),
|
||||
RestoreMode.Full, It.IsAny<CancellationToken>()), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Restore_full_re_increments_software_inventory_via_contributions()
|
||||
{
|
||||
|
||||
@@ -81,6 +81,9 @@ public sealed class SharedManifestParityTests
|
||||
["analyzeRuleSources"] = DryRunAnalyzeRuleFunction.KnownSources.OrderBy(s => s, StringComparer.Ordinal).ToArray(),
|
||||
["analyzeRuleOperators"] = DryRunAnalyzeRuleFunction.KnownOperators.OrderBy(s => s, StringComparer.Ordinal).ToArray(),
|
||||
["eventTypes"] = ConstStrings(typeof(Constants.EventTypes)).OrderBy(s => s, StringComparer.Ordinal).ToArray(),
|
||||
// Declaration order kept: the web derives a union type from this list, order is cosmetic
|
||||
// but a stable order keeps the generated file diff-minimal.
|
||||
["signalRMessages"] = ConstStrings(typeof(Constants.SignalRMessages)),
|
||||
};
|
||||
|
||||
var json = JsonSerializer.Serialize(manifest, new JsonSerializerOptions
|
||||
|
||||
+2
-1
@@ -2,6 +2,7 @@ using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using AutopilotMonitor.Functions.Helpers;
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Shared;
|
||||
using AutopilotMonitor.Shared.DataAccess;
|
||||
@@ -99,7 +100,7 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
|
||||
/// same tick cannot collide. The value is the public backupId.
|
||||
/// </summary>
|
||||
public static string BuildRowKey(DateTime utcNow)
|
||||
=> $"{DateTime.MaxValue.Ticks - utcNow.Ticks:D19}_{Guid.NewGuid():N}"[..28];
|
||||
=> $"{RowKeyCodec.InvertedTicks(utcNow)}_{Guid.NewGuid():N}"[..28];
|
||||
|
||||
private static string Escape(string s) => s.Replace("'", "''");
|
||||
|
||||
|
||||
+2
-1
@@ -2,6 +2,7 @@ using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading.Tasks;
|
||||
using Azure.Data.Tables;
|
||||
using AutopilotMonitor.Functions.Helpers;
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Shared;
|
||||
using AutopilotMonitor.Shared.DataAccess;
|
||||
@@ -29,7 +30,7 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
|
||||
public async Task SaveDistressReportAsync(string tenantId, DistressReportEntry entry)
|
||||
{
|
||||
var pk = tenantId.ToLowerInvariant();
|
||||
var rk = $"{DateTime.MaxValue.Ticks - DateTime.UtcNow.Ticks:D19}";
|
||||
var rk = RowKeyCodec.InvertedTicks(DateTime.UtcNow);
|
||||
|
||||
var entity = new TableEntity(pk, rk)
|
||||
{
|
||||
|
||||
+3
-2
@@ -1,3 +1,4 @@
|
||||
using AutopilotMonitor.Functions.Helpers;
|
||||
using AutopilotMonitor.Functions.Pagination;
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Shared;
|
||||
@@ -36,7 +37,7 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
|
||||
{
|
||||
try
|
||||
{
|
||||
var invertedTicks = (DateTime.MaxValue.Ticks - notification.CreatedAt.Ticks).ToString("D19");
|
||||
var invertedTicks = RowKeyCodec.InvertedTicks(notification.CreatedAt);
|
||||
var notificationId = notification.NotificationId;
|
||||
if (string.IsNullOrEmpty(notificationId))
|
||||
notificationId = Guid.NewGuid().ToString("N")[..12];
|
||||
@@ -199,7 +200,7 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
|
||||
{
|
||||
try
|
||||
{
|
||||
var invertedTicks = (DateTime.MaxValue.Ticks - metadata.SubmittedAt.Ticks).ToString("D19");
|
||||
var invertedTicks = RowKeyCodec.InvertedTicks(metadata.SubmittedAt);
|
||||
var entity = new TableEntity("reports", $"{invertedTicks}_{metadata.ReportId}")
|
||||
{
|
||||
["ReportId"] = metadata.ReportId ?? string.Empty,
|
||||
|
||||
+1
-1
@@ -34,7 +34,7 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
|
||||
public async Task SaveOpsEventAsync(OpsEventEntry entry)
|
||||
{
|
||||
var pk = entry.Category;
|
||||
var rk = $"{DateTime.MaxValue.Ticks - DateTime.UtcNow.Ticks:D19}";
|
||||
var rk = RowKeyCodec.InvertedTicks(DateTime.UtcNow);
|
||||
|
||||
var entity = new TableEntity(pk, rk)
|
||||
{
|
||||
|
||||
+2
-1
@@ -1,3 +1,4 @@
|
||||
using AutopilotMonitor.Functions.Helpers;
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Shared;
|
||||
using AutopilotMonitor.Shared.DataAccess;
|
||||
@@ -33,7 +34,7 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
|
||||
try
|
||||
{
|
||||
var partitionKey = tenantId.ToLowerInvariant();
|
||||
var invertedTicks = (DateTime.MaxValue.Ticks - notification.CreatedAt.Ticks).ToString("D19");
|
||||
var invertedTicks = RowKeyCodec.InvertedTicks(notification.CreatedAt);
|
||||
var notificationId = notification.NotificationId;
|
||||
if (string.IsNullOrEmpty(notificationId))
|
||||
notificationId = Guid.NewGuid().ToString("N")[..12];
|
||||
|
||||
+15
-8
@@ -14,12 +14,12 @@ using Newtonsoft.Json.Linq;
|
||||
namespace AutopilotMonitor.Functions.Functions.Config
|
||||
{
|
||||
/// <summary>
|
||||
/// Transactional field-level patch of a tenant's configuration (GlobalAdminOnly for now;
|
||||
/// phase 2 widens the policy line + caller tier). Unlike the full-model PUT, this takes
|
||||
/// ONLY the fields to change, and the service verifies after the conditional write that
|
||||
/// exactly those fields changed — rolling back automatically on drift. Every write is
|
||||
/// preceded by a fail-closed snapshot into ConfigurationBackups (revertible via
|
||||
/// POST config/{tenantId}/revert).
|
||||
/// Transactional field-level patch of a tenant's configuration (TenantAdminOrGA: a tenant
|
||||
/// admin patches their own row on the stricter TenantAdmin caller tier — GA-only fields are
|
||||
/// an explicit 400 there). Unlike the full-model PUT, this takes ONLY the fields to change,
|
||||
/// and the service verifies after the conditional write that exactly those fields changed —
|
||||
/// rolling back automatically on drift. Every write is preceded by a fail-closed snapshot
|
||||
/// into ConfigurationBackups (revertible via POST config/{tenantId}/revert, GA-only).
|
||||
/// </summary>
|
||||
public class PatchTenantConfigurationFieldsFunction
|
||||
{
|
||||
@@ -49,7 +49,8 @@ namespace AutopilotMonitor.Functions.Functions.Config
|
||||
{
|
||||
try
|
||||
{
|
||||
// Authentication + GlobalAdminOnly authorization enforced by PolicyEnforcementMiddleware.
|
||||
// Authentication + TenantAdminOrGA authorization enforced by PolicyEnforcementMiddleware
|
||||
// (RouteParam scoping binds non-GA callers to their own tenant row).
|
||||
var requestCtx = req.GetRequestContext();
|
||||
|
||||
if (req.Headers.TryGetValues("Content-Length", out var clValues)
|
||||
@@ -79,13 +80,19 @@ namespace AutopilotMonitor.Functions.Functions.Config
|
||||
"PatchTenantConfigurationFields: {TenantId} by {User} ({FieldCount} fields)",
|
||||
requestCtx.TargetTenantId, requestCtx.UserPrincipalName, request.Fields.Count);
|
||||
|
||||
// Caller tier selects the field deny-list: tenant admins additionally lose the
|
||||
// GA-only toggles (explicit 400 instead of the PUT's silent restore).
|
||||
var callerTier = requestCtx.IsGlobalAdmin
|
||||
? TenantConfigCallerTier.GlobalAdmin
|
||||
: TenantConfigCallerTier.TenantAdmin;
|
||||
|
||||
var outcome = await _patchService.ApplyFieldPatchAsync(
|
||||
requestCtx.TargetTenantId,
|
||||
request.Fields,
|
||||
requestCtx.UserPrincipalName,
|
||||
ResolveSource(req, "patch"),
|
||||
request.Reason,
|
||||
TenantConfigCallerTier.GlobalAdmin);
|
||||
callerTier);
|
||||
|
||||
return await WriteOutcome(req, outcome);
|
||||
}
|
||||
|
||||
+1
-1
@@ -195,7 +195,7 @@ namespace AutopilotMonitor.Functions.Functions.Infrastructure
|
||||
[HttpResult]
|
||||
public HttpResponseData? HttpResponse { get; set; }
|
||||
|
||||
[SignalROutput(HubName = "autopilotmonitor")]
|
||||
[SignalROutput(HubName = SignalRGroupHelper.HubName)]
|
||||
public SignalRGroupAction? SignalRGroupAction { get; set; }
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -167,7 +167,7 @@ namespace AutopilotMonitor.Functions.Functions.Infrastructure
|
||||
[HttpResult]
|
||||
public HttpResponseData? HttpResponse { get; set; }
|
||||
|
||||
[SignalROutput(HubName = "autopilotmonitor")]
|
||||
[SignalROutput(HubName = SignalRGroupHelper.HubName)]
|
||||
public SignalRGroupAction? SignalRGroupAction { get; set; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using System.IO;
|
||||
using System.Net;
|
||||
using AutopilotMonitor.Functions.Helpers;
|
||||
using AutopilotMonitor.Functions.Security;
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Functions.Services.Deletion;
|
||||
@@ -614,7 +615,7 @@ namespace AutopilotMonitor.Functions.Functions.Ingest
|
||||
[HttpResult]
|
||||
public HttpResponseData? HttpResponse { get; set; }
|
||||
|
||||
[SignalROutput(HubName = "autopilotmonitor")]
|
||||
[SignalROutput(HubName = SignalRGroupHelper.HubName)]
|
||||
public SignalRMessageAction[]? SignalRMessages { get; set; }
|
||||
}
|
||||
}
|
||||
|
||||
+4
-3
@@ -1,5 +1,6 @@
|
||||
using System.Net;
|
||||
using AutopilotMonitor.Functions.Helpers;
|
||||
using AutopilotMonitor.Shared;
|
||||
using AutopilotMonitor.Shared.DataAccess;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Microsoft.Azure.Functions.Worker;
|
||||
@@ -102,7 +103,7 @@ namespace AutopilotMonitor.Functions.Functions.Sessions
|
||||
sessionUpdate = sessionDelta
|
||||
};
|
||||
|
||||
var tenantMessage = new SignalRMessageAction("newevents")
|
||||
var tenantMessage = new SignalRMessageAction(Constants.SignalRMessages.NewEvents)
|
||||
{
|
||||
GroupName = $"tenant-{tenantId}",
|
||||
Arguments = new[] { messagePayload }
|
||||
@@ -111,7 +112,7 @@ namespace AutopilotMonitor.Functions.Functions.Sessions
|
||||
// Also push to the per-session group: the tenant broadcast group is member-role
|
||||
// gated, so a roleless Progress-Portal watcher of this session would otherwise
|
||||
// never see the admin-marked failure.
|
||||
var sessionMessage = new SignalRMessageAction("newevents")
|
||||
var sessionMessage = new SignalRMessageAction(Constants.SignalRMessages.NewEvents)
|
||||
{
|
||||
GroupName = $"session-{tenantId}-{sessionId}",
|
||||
Arguments = new[] { messagePayload }
|
||||
@@ -156,7 +157,7 @@ namespace AutopilotMonitor.Functions.Functions.Sessions
|
||||
[HttpResult]
|
||||
public HttpResponseData? HttpResponse { get; set; }
|
||||
|
||||
[SignalROutput(HubName = "autopilotmonitor")]
|
||||
[SignalROutput(HubName = SignalRGroupHelper.HubName)]
|
||||
public SignalRMessageAction[]? SignalRMessages { get; set; }
|
||||
}
|
||||
}
|
||||
|
||||
+4
-3
@@ -1,5 +1,6 @@
|
||||
using System.Net;
|
||||
using AutopilotMonitor.Functions.Helpers;
|
||||
using AutopilotMonitor.Shared;
|
||||
using AutopilotMonitor.Shared.DataAccess;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Microsoft.Azure.Functions.Worker;
|
||||
@@ -99,7 +100,7 @@ namespace AutopilotMonitor.Functions.Functions.Sessions
|
||||
sessionUpdate = sessionDelta
|
||||
};
|
||||
|
||||
var tenantMessage = new SignalRMessageAction("newevents")
|
||||
var tenantMessage = new SignalRMessageAction(Constants.SignalRMessages.NewEvents)
|
||||
{
|
||||
GroupName = $"tenant-{tenantId}",
|
||||
Arguments = new[] { messagePayload }
|
||||
@@ -108,7 +109,7 @@ namespace AutopilotMonitor.Functions.Functions.Sessions
|
||||
// Also push to the per-session group: the tenant broadcast group is member-role
|
||||
// gated, so a roleless Progress-Portal watcher of this session would otherwise
|
||||
// never see the admin-marked completion.
|
||||
var sessionMessage = new SignalRMessageAction("newevents")
|
||||
var sessionMessage = new SignalRMessageAction(Constants.SignalRMessages.NewEvents)
|
||||
{
|
||||
GroupName = $"session-{tenantId}-{sessionId}",
|
||||
Arguments = new[] { messagePayload }
|
||||
@@ -153,7 +154,7 @@ namespace AutopilotMonitor.Functions.Functions.Sessions
|
||||
[HttpResult]
|
||||
public HttpResponseData? HttpResponse { get; set; }
|
||||
|
||||
[SignalROutput(HubName = "autopilotmonitor")]
|
||||
[SignalROutput(HubName = SignalRGroupHelper.HubName)]
|
||||
public SignalRMessageAction[]? SignalRMessages { get; set; }
|
||||
}
|
||||
}
|
||||
|
||||
+5
-4
@@ -1,5 +1,6 @@
|
||||
using AutopilotMonitor.Shared;
|
||||
using System.Net;
|
||||
using AutopilotMonitor.Functions.Helpers;
|
||||
using AutopilotMonitor.Functions.Security;
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Functions.Services.Deletion;
|
||||
@@ -241,14 +242,14 @@ namespace AutopilotMonitor.Functions.Functions.Sessions
|
||||
};
|
||||
|
||||
// 1. Tenant-specific notification (normal users)
|
||||
var tenantMessage = new SignalRMessageAction("newSession")
|
||||
var tenantMessage = new SignalRMessageAction(Constants.SignalRMessages.NewSession)
|
||||
{
|
||||
GroupName = $"tenant-{registration.TenantId}",
|
||||
Arguments = new[] { messagePayload }
|
||||
};
|
||||
|
||||
// 2. Global Admins notification (cross-tenant visibility)
|
||||
var globalAdminMessage = new SignalRMessageAction("newSession")
|
||||
var globalAdminMessage = new SignalRMessageAction(Constants.SignalRMessages.NewSession)
|
||||
{
|
||||
GroupName = "global-admins",
|
||||
Arguments = new[] { messagePayload }
|
||||
@@ -313,7 +314,7 @@ namespace AutopilotMonitor.Functions.Functions.Sessions
|
||||
"Superseded stale {StaleStatus} session {StaleSessionId} (started {StaleStartedAt:yyyy-MM-dd}) with new session {SessionId} for device serial (tenant {TenantId})",
|
||||
stale.Status, stale.SessionId, stale.StartedAt, registration.SessionId, registration.TenantId);
|
||||
|
||||
messages.Add(new SignalRMessageAction("newevents")
|
||||
messages.Add(new SignalRMessageAction(Constants.SignalRMessages.NewEvents)
|
||||
{
|
||||
GroupName = $"tenant-{registration.TenantId}",
|
||||
Arguments = new object[] { new {
|
||||
@@ -408,7 +409,7 @@ namespace AutopilotMonitor.Functions.Functions.Sessions
|
||||
[HttpResult]
|
||||
public HttpResponseData? HttpResponse { get; set; }
|
||||
|
||||
[SignalROutput(HubName = "autopilotmonitor")]
|
||||
[SignalROutput(HubName = SignalRGroupHelper.HubName)]
|
||||
public SignalRMessageAction[]? SignalRMessages { get; set; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -94,21 +94,7 @@ namespace AutopilotMonitor.Functions.Helpers
|
||||
}
|
||||
|
||||
private static bool TryDecodeReverseTicks(ReadOnlySpan<char> revTickDigits, out DateTime utc)
|
||||
{
|
||||
utc = default;
|
||||
foreach (var c in revTickDigits)
|
||||
{
|
||||
if (c < '0' || c > '9')
|
||||
return false;
|
||||
}
|
||||
if (!long.TryParse(revTickDigits, NumberStyles.None, CultureInfo.InvariantCulture, out var rev))
|
||||
return false;
|
||||
var ticks = DateTime.MaxValue.Ticks - rev;
|
||||
if (ticks < DateTime.MinValue.Ticks || ticks > DateTime.MaxValue.Ticks)
|
||||
return false;
|
||||
utc = new DateTime(ticks, DateTimeKind.Utc);
|
||||
return true;
|
||||
}
|
||||
=> RowKeyCodec.TryDecodeInvertedTicks(revTickDigits, out utc);
|
||||
|
||||
// ===== OData date-window clauses (RowKey ranges) =====
|
||||
// Date windows filter on the RowKey rather than a property: rows missing a property are
|
||||
@@ -117,7 +103,7 @@ namespace AutopilotMonitor.Functions.Helpers
|
||||
// because the reverse-tick is fixed-width D19. rev(t) = MaxTicks - t.Ticks, so larger
|
||||
// timestamps have lexically smaller RowKeys (newest-first).
|
||||
|
||||
private static long Rev(DateTime utc) => DateTime.MaxValue.Ticks - utc.Ticks;
|
||||
private static long Rev(DateTime utc) => RowKeyCodec.InvertedTicksValue(utc);
|
||||
|
||||
/// <summary>Audit rows with business time ≥ <paramref name="fromUtc"/> (inclusive):
|
||||
/// rev ≤ rev(from) < rev(from)+1. The exclusive upper bound also excludes all legacy
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
using System;
|
||||
using System.Globalization;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Helpers
|
||||
{
|
||||
/// <summary>
|
||||
/// Single encoder/decoder for inverted-tick RowKeys: rev(t) = DateTime.MaxValue.Ticks - t.Ticks,
|
||||
/// zero-padded to a fixed width so lexicographic RowKey order equals newest-first time order.
|
||||
///
|
||||
/// Every format built on top of this codec is PERSISTED in production tables — the codec
|
||||
/// unifies construction, it must never change an existing table's padding/prefix/suffix shape:
|
||||
/// D19 (standard): SessionsIndex/EventTypeIndex "{D19}_{sessionId}", AuditLogs
|
||||
/// "!{D19}_{guid:N}", OpsEvents/DistressReports bare "{D19}", Notifications/
|
||||
/// TenantNotifications/SessionReports "{D19}_{id}", ConfigurationBackups "({D19}_{guid:N})[..28]".
|
||||
/// D20: UserActivity ONLY — its existing rows pin the 20-digit width; a 19-digit key would
|
||||
/// sort after every 20-digit key and silently break that table's newest-first ordering.
|
||||
/// </summary>
|
||||
internal static class RowKeyCodec
|
||||
{
|
||||
/// <summary>Raw reverse-tick value; use for OData clause arithmetic (e.g. rev+1 bounds).</summary>
|
||||
internal static long InvertedTicksValue(DateTime utc) => DateTime.MaxValue.Ticks - utc.Ticks;
|
||||
|
||||
/// <summary>Standard fixed-width D19 inverted-tick encoding.</summary>
|
||||
internal static string InvertedTicks(DateTime utc)
|
||||
=> InvertedTicksValue(utc).ToString("D19", CultureInfo.InvariantCulture);
|
||||
|
||||
/// <summary>UserActivity-only legacy D20 width — see class remarks. Do not use for new tables.</summary>
|
||||
internal static string InvertedTicksD20(DateTime utc)
|
||||
=> InvertedTicksValue(utc).ToString("D20", CultureInfo.InvariantCulture);
|
||||
|
||||
/// <summary>
|
||||
/// Decodes a fixed-width inverted-tick digit run back to the original UTC instant
|
||||
/// (Kind=Utc). Rejects non-digit characters and out-of-range values.
|
||||
/// </summary>
|
||||
internal static bool TryDecodeInvertedTicks(ReadOnlySpan<char> revTickDigits, out DateTime utc)
|
||||
{
|
||||
utc = default;
|
||||
foreach (var c in revTickDigits)
|
||||
{
|
||||
if (c < '0' || c > '9')
|
||||
return false;
|
||||
}
|
||||
if (!long.TryParse(revTickDigits, NumberStyles.None, CultureInfo.InvariantCulture, out var rev))
|
||||
return false;
|
||||
var ticks = DateTime.MaxValue.Ticks - rev;
|
||||
if (ticks < DateTime.MinValue.Ticks || ticks > DateTime.MaxValue.Ticks)
|
||||
return false;
|
||||
utc = new DateTime(ticks, DateTimeKind.Utc);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,12 @@ namespace AutopilotMonitor.Functions.Helpers;
|
||||
/// </summary>
|
||||
public static class SignalRGroupHelper
|
||||
{
|
||||
/// <summary>
|
||||
/// The single SignalR hub every binding and imperative send targets. Const so it is usable
|
||||
/// in [SignalROutput(HubName = ...)] attribute arguments.
|
||||
/// </summary>
|
||||
public const string HubName = "autopilotmonitor";
|
||||
|
||||
public const string TenantNotifyMemberSuffix = "-notify-member";
|
||||
public const string TenantNotifyAdminSuffix = "-notify-admin";
|
||||
|
||||
|
||||
@@ -414,12 +414,14 @@ public static class EndpointAccessPolicyCatalog
|
||||
new("PUT", "global/config", EndpointPolicy.GlobalAdminOnly),
|
||||
new("POST", "global/config", EndpointPolicy.GlobalAdminOnly),
|
||||
new("GET", "config/all", EndpointPolicy.GlobalReadOrDelegatedSubset),
|
||||
// Transactional field-level config writes + snapshot list/revert (MCP-first surface).
|
||||
// GlobalAdminOnly for now; phase 2 widens these three lines to TenantAdminOrGA (own row)
|
||||
// and a delegated-admin write policy — the service's caller-tier field deny-lists and
|
||||
// TenantScoping.RouteParam already carry the per-tenant guardrail. The backups GET is
|
||||
// metadata-only (masked diff, never the raw EntityJson snapshot).
|
||||
new("PATCH", "config/{tenantId}/fields", EndpointPolicy.GlobalAdminOnly, TenantScoping.RouteParam),
|
||||
// Transactional field-level config writes + snapshot list/revert (MCP + web Settings).
|
||||
// PATCH is TenantAdminOrGA (phase 2, the web's per-section save): RouteParam binds a
|
||||
// tenant admin to their OWN row, and the service's caller-tier deny-list turns the
|
||||
// GA-only toggles into explicit 400s for the TenantAdmin tier. Backups GET + revert
|
||||
// POST stay GlobalAdminOnly (operator-only restore surface; the backups GET is
|
||||
// metadata-only — masked diff, never the raw EntityJson snapshot). Delegated (MSP)
|
||||
// config writes remain a future policy decision.
|
||||
new("PATCH", "config/{tenantId}/fields", EndpointPolicy.TenantAdminOrGA, TenantScoping.RouteParam),
|
||||
new("GET", "config/{tenantId}/backups", EndpointPolicy.GlobalAdminOnly, TenantScoping.RouteParam),
|
||||
new("POST", "config/{tenantId}/revert", EndpointPolicy.GlobalAdminOnly, TenantScoping.RouteParam),
|
||||
// Field schema for the write surface above: tenant-independent reflection over the model
|
||||
|
||||
@@ -323,13 +323,20 @@ namespace AutopilotMonitor.Functions.Services.Deletion
|
||||
{
|
||||
var rows = new List<DeletionRowDump>(2);
|
||||
// §5 PR4 mandates SessionsIndex deleted FIRST so UI listings drop the session before the canonical row goes.
|
||||
// The FINAL step mixes two tables while DeletionStep.Table stays null, so each row
|
||||
// carries its source table explicitly (DeletionTombstoneTables.Resolve falls back to
|
||||
// the RowKey-shape heuristic only for manifests written before the field existed).
|
||||
if (sessionsIndexRow != null)
|
||||
{
|
||||
rows.Add(TableEntityDumpConverter.MapEntityToDump(sessionsIndexRow));
|
||||
var dump = TableEntityDumpConverter.MapEntityToDump(sessionsIndexRow);
|
||||
dump.Table = Constants.TableNames.SessionsIndex;
|
||||
rows.Add(dump);
|
||||
}
|
||||
if (sessionRow != null)
|
||||
{
|
||||
rows.Add(TableEntityDumpConverter.MapEntityToDump(sessionRow));
|
||||
var dump = TableEntityDumpConverter.MapEntityToDump(sessionRow);
|
||||
dump.Table = Constants.TableNames.Sessions;
|
||||
rows.Add(dump);
|
||||
}
|
||||
manifest.Steps.Add(new DeletionStep
|
||||
{
|
||||
|
||||
@@ -544,28 +544,17 @@ namespace AutopilotMonitor.Functions.Services.Deletion
|
||||
ct);
|
||||
|
||||
// The builder (DeletionManifestBuilder.AddTombstoneStep) emits SessionsIndex first
|
||||
// (if present) then Sessions. Determine table by row.Rk shape: anything matching the
|
||||
// session id is the Sessions row; everything else is SessionsIndex. This works even
|
||||
// if the manifest carries only one of the two rows (corruption-recovery scenario).
|
||||
// (if present) then Sessions, each row carrying its source table (legacy manifests
|
||||
// without the field resolve via the RowKey-shape heuristic). Works even if the
|
||||
// manifest carries only one of the two rows (corruption-recovery scenario).
|
||||
foreach (var row in tombstone.Rows)
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
var table = IsSessionsRow(row) ? Constants.TableNames.Sessions : Constants.TableNames.SessionsIndex;
|
||||
var table = DeletionTombstoneTables.Resolve(row);
|
||||
await _storage.DeleteByExactKeysInBatchesAsync(table, new[] { (row.Pk, row.Rk) }, ct);
|
||||
}
|
||||
}
|
||||
|
||||
private static bool IsSessionsRow(DeletionRowDump row)
|
||||
{
|
||||
// Sessions row: PK = {tenantId}, RK = {sessionId}.
|
||||
// SessionsIndex row: PK = {tenantId}, RK = {indexRowKey} (typically inverted-timestamp + sessionId).
|
||||
// The discriminator is whether the RowKey *equals* a sessionId-shaped string. We use a
|
||||
// simple heuristic: if the RowKey contains a "_" the row is the SessionsIndex (its
|
||||
// RK is always composed); otherwise it's the Sessions row. This matches the builder's
|
||||
// current schema (Sessions RK is the bare session GUID, IndexRowKey is "inverted_…_GUID").
|
||||
return row.Rk != null && !row.Rk.Contains('_');
|
||||
}
|
||||
|
||||
// ============================================================ Progress CAS retry ====
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -233,7 +233,7 @@ namespace AutopilotMonitor.Functions.Services.Deletion
|
||||
{
|
||||
foreach (var row in tombstone.Rows)
|
||||
{
|
||||
var t = IsSessionsRow(row) ? Constants.TableNames.Sessions : Constants.TableNames.SessionsIndex;
|
||||
var t = DeletionTombstoneTables.Resolve(row);
|
||||
if (!result.WouldRestoreByTable.TryGetValue(t, out var c)) c = 0;
|
||||
result.WouldRestoreByTable[t] = c + 1;
|
||||
}
|
||||
@@ -346,7 +346,7 @@ namespace AutopilotMonitor.Functions.Services.Deletion
|
||||
foreach (var row in tombstone.Rows)
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
var table = IsSessionsRow(row) ? Constants.TableNames.Sessions : Constants.TableNames.SessionsIndex;
|
||||
var table = DeletionTombstoneTables.Resolve(row);
|
||||
var res = await _storage.RestoreRowsByExactKeysInBatchesAsync(
|
||||
table, new[] { row }, RestoreMode.Full, ct).ConfigureAwait(false);
|
||||
AddCount(result.RowsRestoredByTable, table, res.Restored);
|
||||
@@ -439,7 +439,7 @@ namespace AutopilotMonitor.Functions.Services.Deletion
|
||||
foreach (var row in tombstone.Rows)
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
var table = IsSessionsRow(row) ? Constants.TableNames.Sessions : Constants.TableNames.SessionsIndex;
|
||||
var table = DeletionTombstoneTables.Resolve(row);
|
||||
var res = await _storage.RestoreRowsByExactKeysInBatchesAsync(
|
||||
table, new[] { row }, RestoreMode.Partial, ct).ConfigureAwait(false);
|
||||
AddCount(result.RowsRestoredByTable, table, res.Restored);
|
||||
@@ -579,8 +579,6 @@ namespace AutopilotMonitor.Functions.Services.Deletion
|
||||
|
||||
// ============================================================ Helpers ====
|
||||
|
||||
private static bool IsSessionsRow(DeletionRowDump row)
|
||||
=> row.Rk != null && !row.Rk.Contains('_');
|
||||
|
||||
private static void AddCount(Dictionary<string, int> map, string key, int delta)
|
||||
{
|
||||
|
||||
+2
-2
@@ -137,7 +137,7 @@ namespace AutopilotMonitor.Functions.Services
|
||||
updatedSession.IsPreProvisioned
|
||||
} : null;
|
||||
|
||||
var summaryMessage = new SignalRMessageAction("newevents")
|
||||
var summaryMessage = new SignalRMessageAction(Constants.SignalRMessages.NewEvents)
|
||||
{
|
||||
GroupName = $"tenant-{request.TenantId}",
|
||||
Arguments = new object[] { new {
|
||||
@@ -164,7 +164,7 @@ namespace AutopilotMonitor.Functions.Services
|
||||
}).ToList<object>()
|
||||
: null;
|
||||
|
||||
var eventsMessage = new SignalRMessageAction("eventStream")
|
||||
var eventsMessage = new SignalRMessageAction(Constants.SignalRMessages.EventStream)
|
||||
{
|
||||
GroupName = $"session-{request.TenantId}-{request.SessionId}",
|
||||
Arguments = new object[] { new {
|
||||
|
||||
@@ -2,6 +2,7 @@ using System;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using AutopilotMonitor.Functions.Helpers;
|
||||
using AutopilotMonitor.Shared;
|
||||
using Microsoft.Azure.SignalR.Management;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.Logging;
|
||||
@@ -20,7 +21,7 @@ namespace AutopilotMonitor.Functions.Services
|
||||
private ServiceHubContext? _hubContext;
|
||||
private static readonly SemaphoreSlim _initLock = new(1, 1);
|
||||
|
||||
private const string HubName = "autopilotmonitor";
|
||||
private const string HubName = SignalRGroupHelper.HubName;
|
||||
|
||||
/// <summary>
|
||||
/// Hard ceiling for every imperative send (hub-context resolution + SendCoreAsync).
|
||||
@@ -64,7 +65,7 @@ namespace AutopilotMonitor.Functions.Services
|
||||
var hub = await GetHubContextAsync(cts.Token);
|
||||
var groupName = $"session-{tenantId}-{sessionId}";
|
||||
|
||||
await hub.Clients.Group(groupName).SendCoreAsync("ruleResultsReady", new object[]
|
||||
await hub.Clients.Group(groupName).SendCoreAsync(Constants.SignalRMessages.RuleResultsReady, new object[]
|
||||
{
|
||||
new
|
||||
{
|
||||
@@ -94,7 +95,7 @@ namespace AutopilotMonitor.Functions.Services
|
||||
var hub = await GetHubContextAsync(cts.Token);
|
||||
var groupName = $"session-{tenantId}-{sessionId}";
|
||||
|
||||
await hub.Clients.Group(groupName).SendCoreAsync("vulnerabilityReportReady", new object[]
|
||||
await hub.Clients.Group(groupName).SendCoreAsync(Constants.SignalRMessages.VulnerabilityReportReady, new object[]
|
||||
{
|
||||
new
|
||||
{
|
||||
@@ -127,7 +128,7 @@ namespace AutopilotMonitor.Functions.Services
|
||||
var hub = await GetHubContextAsync(cts.Token);
|
||||
var groupName = $"session-{tenantId}-{sessionId}";
|
||||
|
||||
await hub.Clients.Group(groupName).SendCoreAsync("sessionDeleted", new object[]
|
||||
await hub.Clients.Group(groupName).SendCoreAsync(Constants.SignalRMessages.SessionDeleted, new object[]
|
||||
{
|
||||
new
|
||||
{
|
||||
@@ -160,7 +161,7 @@ namespace AutopilotMonitor.Functions.Services
|
||||
? SignalRGroupHelper.TenantNotifyAdminGroup(tenantId)
|
||||
: SignalRGroupHelper.TenantNotifyMemberGroup(tenantId);
|
||||
|
||||
await hub.Clients.Group(groupName).SendCoreAsync("tenantNotification", new[] { dto }, cts.Token);
|
||||
await hub.Clients.Group(groupName).SendCoreAsync(Constants.SignalRMessages.TenantNotification, new[] { dto }, cts.Token);
|
||||
_logger.LogDebug("Sent tenantNotification to group {Group}", groupName);
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -185,8 +186,8 @@ namespace AutopilotMonitor.Functions.Services
|
||||
var payload = new { id = notificationId };
|
||||
|
||||
await Task.WhenAll(
|
||||
hub.Clients.Group(memberGroup).SendCoreAsync("tenantNotificationDismissed", new object[] { payload }, cts.Token),
|
||||
hub.Clients.Group(adminGroup).SendCoreAsync("tenantNotificationDismissed", new object[] { payload }, cts.Token)
|
||||
hub.Clients.Group(memberGroup).SendCoreAsync(Constants.SignalRMessages.TenantNotificationDismissed, new object[] { payload }, cts.Token),
|
||||
hub.Clients.Group(adminGroup).SendCoreAsync(Constants.SignalRMessages.TenantNotificationDismissed, new object[] { payload }, cts.Token)
|
||||
);
|
||||
_logger.LogDebug("Sent tenantNotificationDismissed for tenant {TenantId} id {Id}", tenantId, notificationId);
|
||||
}
|
||||
@@ -209,8 +210,8 @@ namespace AutopilotMonitor.Functions.Services
|
||||
var adminGroup = SignalRGroupHelper.TenantNotifyAdminGroup(tenantId);
|
||||
|
||||
await Task.WhenAll(
|
||||
hub.Clients.Group(memberGroup).SendCoreAsync("tenantNotificationsDismissedAll", Array.Empty<object>(), cts.Token),
|
||||
hub.Clients.Group(adminGroup).SendCoreAsync("tenantNotificationsDismissedAll", Array.Empty<object>(), cts.Token)
|
||||
hub.Clients.Group(memberGroup).SendCoreAsync(Constants.SignalRMessages.TenantNotificationsDismissedAll, Array.Empty<object>(), cts.Token),
|
||||
hub.Clients.Group(adminGroup).SendCoreAsync(Constants.SignalRMessages.TenantNotificationsDismissedAll, Array.Empty<object>(), cts.Token)
|
||||
);
|
||||
_logger.LogDebug("Sent tenantNotificationsDismissedAll for tenant {TenantId}", tenantId);
|
||||
}
|
||||
@@ -229,7 +230,7 @@ namespace AutopilotMonitor.Functions.Services
|
||||
{
|
||||
using var cts = new CancellationTokenSource(SendTimeout);
|
||||
var hub = await GetHubContextAsync(cts.Token);
|
||||
await hub.Clients.Group(SignalRGroupHelper.GlobalAdminsGroup).SendCoreAsync("globalNotification", new[] { dto }, cts.Token);
|
||||
await hub.Clients.Group(SignalRGroupHelper.GlobalAdminsGroup).SendCoreAsync(Constants.SignalRMessages.GlobalNotification, new[] { dto }, cts.Token);
|
||||
_logger.LogDebug("Sent globalNotification to group {Group}", SignalRGroupHelper.GlobalAdminsGroup);
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -245,7 +246,7 @@ namespace AutopilotMonitor.Functions.Services
|
||||
using var cts = new CancellationTokenSource(SendTimeout);
|
||||
var hub = await GetHubContextAsync(cts.Token);
|
||||
var payload = new { id = notificationId };
|
||||
await hub.Clients.Group(SignalRGroupHelper.GlobalAdminsGroup).SendCoreAsync("globalNotificationDismissed", new object[] { payload }, cts.Token);
|
||||
await hub.Clients.Group(SignalRGroupHelper.GlobalAdminsGroup).SendCoreAsync(Constants.SignalRMessages.GlobalNotificationDismissed, new object[] { payload }, cts.Token);
|
||||
_logger.LogDebug("Sent globalNotificationDismissed for id {Id}", notificationId);
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -260,7 +261,7 @@ namespace AutopilotMonitor.Functions.Services
|
||||
{
|
||||
using var cts = new CancellationTokenSource(SendTimeout);
|
||||
var hub = await GetHubContextAsync(cts.Token);
|
||||
await hub.Clients.Group(SignalRGroupHelper.GlobalAdminsGroup).SendCoreAsync("globalNotificationsDismissedAll", Array.Empty<object>(), cts.Token);
|
||||
await hub.Clients.Group(SignalRGroupHelper.GlobalAdminsGroup).SendCoreAsync(Constants.SignalRMessages.GlobalNotificationsDismissedAll, Array.Empty<object>(), cts.Token);
|
||||
_logger.LogDebug("Sent globalNotificationsDismissedAll");
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -312,7 +313,7 @@ namespace AutopilotMonitor.Functions.Services
|
||||
using var cts = new CancellationTokenSource(SendTimeout);
|
||||
var hub = await GetHubContextAsync(cts.Token);
|
||||
await hub.UserGroups.RemoveFromAllGroupsAsync(userId, cts.Token);
|
||||
await hub.Clients.User(userId).SendCoreAsync("accessRevoked", new object[]
|
||||
await hub.Clients.User(userId).SendCoreAsync(Constants.SignalRMessages.AccessRevoked, new object[]
|
||||
{
|
||||
new { timestamp = DateTime.UtcNow.ToString("o") }
|
||||
}, cts.Token);
|
||||
|
||||
@@ -43,7 +43,9 @@ namespace AutopilotMonitor.Functions.Services
|
||||
startedAt = eventList.Min(e => e.Timestamp);
|
||||
}
|
||||
|
||||
var rowKey = $"{(DateTime.MaxValue.Ticks - startedAt.Ticks):D19}_{sessionId}";
|
||||
// Same shape as the SessionsIndex key so per-event-type listings share the
|
||||
// newest-first ordering (partial-class sibling in TableStorageService.Sessions.cs).
|
||||
var rowKey = ComputeIndexRowKey(startedAt, sessionId);
|
||||
|
||||
var groups = eventList.GroupBy(e => e.EventType);
|
||||
|
||||
|
||||
@@ -31,10 +31,7 @@ namespace AutopilotMonitor.Functions.Services
|
||||
/// of the ordering invariants.
|
||||
/// </summary>
|
||||
internal static string BuildAuditLogRowKey(DateTime timestampUtc, Guid collisionSuffix)
|
||||
{
|
||||
var revTick = DateTime.MaxValue.Ticks - timestampUtc.Ticks;
|
||||
return $"{AuditLogRowKeyPrefix}{revTick:D19}_{collisionSuffix:N}";
|
||||
}
|
||||
=> $"{AuditLogRowKeyPrefix}{RowKeyCodec.InvertedTicks(timestampUtc)}_{collisionSuffix:N}";
|
||||
|
||||
/// <summary>
|
||||
/// Logs an audit entry. RowKey uses a fixed prefix + reverse-tick (newest-first
|
||||
|
||||
@@ -949,7 +949,7 @@ namespace AutopilotMonitor.Functions.Services
|
||||
{
|
||||
var tableClient = _tableServiceClient.GetTableClient(Constants.TableNames.UserActivity);
|
||||
var now = DateTime.UtcNow;
|
||||
var invertedTicks = (DateTime.MaxValue.Ticks - now.Ticks).ToString("D20");
|
||||
var invertedTicks = RowKeyCodec.InvertedTicksD20(now);
|
||||
|
||||
var entity = new TableEntity(tenantId, $"{invertedTicks}_{Guid.NewGuid():N}")
|
||||
{
|
||||
|
||||
@@ -22,7 +22,7 @@ namespace AutopilotMonitor.Functions.Services
|
||||
/// Format: "{invertedTicks:D19}_{sessionId}" to guarantee uniqueness.
|
||||
/// </summary>
|
||||
private static string ComputeIndexRowKey(DateTime startedAt, string sessionId)
|
||||
=> $"{(DateTime.MaxValue.Ticks - startedAt.Ticks):D19}_{sessionId}";
|
||||
=> $"{RowKeyCodec.InvertedTicks(startedAt)}_{sessionId}";
|
||||
|
||||
/// <summary>
|
||||
/// Computes the SessionsIndex RowKey upper bound (D19 inverted-tick prefix) for a
|
||||
@@ -32,7 +32,7 @@ namespace AutopilotMonitor.Functions.Services
|
||||
internal static string ComputeCutoffRowKeyPrefix(int days)
|
||||
{
|
||||
var cutoffDate = DateTime.UtcNow.AddDays(-days);
|
||||
return $"{(DateTime.MaxValue.Ticks - cutoffDate.Ticks):D19}";
|
||||
return RowKeyCodec.InvertedTicks(cutoffDate);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -834,6 +834,36 @@ namespace AutopilotMonitor.Shared
|
||||
public const string DelegatedAdmin = "DelegatedAdmin";
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// SignalR message names
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/// <summary>
|
||||
/// The complete catalog of SignalR message (target) names sent by the backend and
|
||||
/// subscribed by the web portal. Exported into shared-manifests.json by
|
||||
/// SharedManifestParityTests; the web's SignalRContext types its on/off event names
|
||||
/// against the generated manifest, so adding/renaming a name here without regenerating
|
||||
/// the manifest fails xUnit, and a web subscription to an unknown name fails tsc.
|
||||
/// NOTE: "newevents" predates the camelCase convention the other names follow — it is
|
||||
/// a persisted wire name, do not "fix" the casing.
|
||||
/// </summary>
|
||||
public static class SignalRMessages
|
||||
{
|
||||
public const string NewSession = "newSession";
|
||||
public const string NewEvents = "newevents";
|
||||
public const string EventStream = "eventStream";
|
||||
public const string RuleResultsReady = "ruleResultsReady";
|
||||
public const string VulnerabilityReportReady = "vulnerabilityReportReady";
|
||||
public const string SessionDeleted = "sessionDeleted";
|
||||
public const string TenantNotification = "tenantNotification";
|
||||
public const string TenantNotificationDismissed = "tenantNotificationDismissed";
|
||||
public const string TenantNotificationsDismissedAll = "tenantNotificationsDismissedAll";
|
||||
public const string GlobalNotification = "globalNotification";
|
||||
public const string GlobalNotificationDismissed = "globalNotificationDismissed";
|
||||
public const string GlobalNotificationsDismissedAll = "globalNotificationsDismissedAll";
|
||||
public const string AccessRevoked = "accessRevoked";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Lifecycle status of a DelegatedAdmins assignment row. Only <see cref="Active"/> rows confer scope;
|
||||
/// <see cref="PendingApproval"/> (customer-delegation awaiting tenant-admin approval) and
|
||||
|
||||
@@ -127,9 +127,38 @@ namespace AutopilotMonitor.Shared.Models.Deletion
|
||||
/// <summary>ETag as observed at preflight; informational only (not used for restore).</summary>
|
||||
public string? Etag { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Source table of this row. Set only on FINAL (Tombstone) rows — that step mixes rows
|
||||
/// from two tables while <see cref="DeletionStep.Table"/> is null. Null on rows of
|
||||
/// table-targeted steps (their step carries the table) and on manifests written before
|
||||
/// the field existed; resolve via <see cref="DeletionTombstoneTables.Resolve"/>.
|
||||
/// </summary>
|
||||
public string? Table { get; set; }
|
||||
|
||||
public Dictionary<string, DeletionPropValue> Props { get; set; } = new Dictionary<string, DeletionPropValue>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolves which table a FINAL (Tombstone) row belongs to. Rows written since 2026-08 carry
|
||||
/// <see cref="DeletionRowDump.Table"/> explicitly; older manifests (alive for up to the
|
||||
/// manifest retention window) fall back to the historical RowKey-shape heuristic: the
|
||||
/// Sessions RK is the bare session GUID while the SessionsIndex RK is composed
|
||||
/// ("{invertedTicks}_{sessionId}"), so a '_' marks the index row. The fallback must survive
|
||||
/// at least one full manifest-retention cycle after the field's introduction.
|
||||
/// </summary>
|
||||
public static class DeletionTombstoneTables
|
||||
{
|
||||
public static string Resolve(DeletionRowDump row)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(row.Table))
|
||||
return row.Table!;
|
||||
// IndexOf (not Contains(char)): Shared targets netstandard2.0 for the net48 agent.
|
||||
return row.Rk != null && row.Rk.IndexOf('_') < 0
|
||||
? Constants.TableNames.Sessions
|
||||
: Constants.TableNames.SessionsIndex;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Single Azure Table column value with its EDM type tag. Restore looks at <see cref="EdmType"/>
|
||||
/// to decide which <c>Get*</c> / strongly-typed entity setter to use; without the tag, a string
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { sessionUrl } from "@/lib/routes";
|
||||
import { api } from "@/lib/api";
|
||||
import { scopedApi } from "@/lib/scopedApi";
|
||||
import { API_BASE_URL } from "@/utils/config";
|
||||
import { authenticatedFetch } from "@/lib/authenticatedFetch";
|
||||
import { ProtectedRoute } from "@/components/ProtectedRoute";
|
||||
@@ -63,7 +63,7 @@ export default function AnnotationsPage() {
|
||||
// GA/Reader: "All tenants" aggregate by default (mirrors the MCP evaluation stream) with
|
||||
// per-tenant drill-down; delegated ("MSP"): managed tenants only, never aggregated.
|
||||
const scope = useAggregatedAdminScope({ defaultAggregated: true });
|
||||
const { isGlobalAdmin: crossTenant, routeGlobal, selectedTenantId, scopeInitialized, tenants } = scope;
|
||||
const { isGlobalAdmin: crossTenant, routeGlobal, selectedTenantId, effectiveTenantId, scopeInitialized, tenants } = scope;
|
||||
|
||||
const lanes = visibleLanes(user);
|
||||
|
||||
@@ -108,14 +108,12 @@ export default function AnnotationsPage() {
|
||||
lane: laneFilter || undefined,
|
||||
};
|
||||
await fetchPage(
|
||||
routeGlobal
|
||||
? api.annotations.globalList({ ...filters, tenantId: selectedTenantId || undefined })
|
||||
: api.annotations.list(filters),
|
||||
scopedApi.annotationsList({ routeGlobal, selectedTenantId, effectiveTenantId }, filters),
|
||||
false
|
||||
);
|
||||
};
|
||||
void loadFirstPage();
|
||||
}, [fetchPage, verdictFilter, laneFilter, scopeInitialized, routeGlobal, selectedTenantId]);
|
||||
}, [fetchPage, verdictFilter, laneFilter, scopeInitialized, routeGlobal, selectedTenantId, effectiveTenantId]);
|
||||
|
||||
return (
|
||||
<ProtectedRoute>
|
||||
|
||||
@@ -6,7 +6,7 @@ import { useRouter } from "next/navigation";
|
||||
import { useTenant } from "../../../contexts/TenantContext";
|
||||
import { useAuth } from "../../../contexts/AuthContext";
|
||||
import { useNotifications } from "../../../contexts/NotificationContext";
|
||||
import { api } from "@/lib/api";
|
||||
import { scopedApi } from "@/lib/scopedApi";
|
||||
import { authenticatedFetch, TokenExpiredError } from "@/lib/authenticatedFetch";
|
||||
import { formatBytes, formatDuration } from "@/lib/formatting";
|
||||
import DoBreakdownBar from "@/components/DoBreakdownBar";
|
||||
@@ -82,7 +82,7 @@ export default function InstallsTab({ scope, timeRange }: InstallsTabProps) {
|
||||
const { tenantId } = useTenant();
|
||||
const { getAccessToken } = useAuth();
|
||||
const { addNotification } = useNotifications();
|
||||
const { isGlobalAdmin, routeGlobal, selectedTenantId, scopeInitialized, scopeKey } = scope;
|
||||
const { isGlobalAdmin, scopeInitialized, scopeKey } = scope;
|
||||
|
||||
const [data, setData] = useState<AppsListResponse | null>(null);
|
||||
const [doRollup, setDoRollup] = useState<DeliveryOptimizationRollup | null>(null);
|
||||
@@ -106,12 +106,8 @@ export default function InstallsTab({ scope, timeRange }: InstallsTabProps) {
|
||||
try {
|
||||
setLoading(true);
|
||||
progressBegin();
|
||||
const listUrl = routeGlobal
|
||||
? api.apps.globalList(days, selectedTenantId || undefined)
|
||||
: api.apps.list(tenantId, days);
|
||||
const metricsUrl = routeGlobal
|
||||
? api.metrics.globalApp(days, selectedTenantId || undefined)
|
||||
: api.metrics.app(tenantId, days);
|
||||
const listUrl = scopedApi.appsList(scope, days);
|
||||
const metricsUrl = scopedApi.appMetrics(scope, days);
|
||||
|
||||
const [listRes, metricsRes] = await Promise.all([
|
||||
authenticatedFetch(listUrl, getAccessToken, { signal: AbortSignal.timeout(APPS_FETCH_TIMEOUT_MS) }),
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useAuth } from "../../../contexts/AuthContext";
|
||||
import { useNotifications } from "../../../contexts/NotificationContext";
|
||||
import { api } from "@/lib/api";
|
||||
import { scopedApi } from "@/lib/scopedApi";
|
||||
import { authenticatedFetch, TokenExpiredError } from "@/lib/authenticatedFetch";
|
||||
import VulnerabilityExposurePanel, { type VulnerabilitySummary } from "@/components/VulnerabilityExposurePanel";
|
||||
import type { SoftwareTabScope, TimeRange } from "./types";
|
||||
@@ -14,7 +14,7 @@ const TOP_N = 20;
|
||||
export default function VulnerabilitiesTab({ scope, timeRange }: { scope: SoftwareTabScope; timeRange: TimeRange }) {
|
||||
const { getAccessToken } = useAuth();
|
||||
const { addNotification } = useNotifications();
|
||||
const { isGlobalAdmin, routeGlobal, selectedTenantId, scopeInitialized, scopeKey } = scope;
|
||||
const { isGlobalAdmin, selectedTenantId, scopeInitialized, scopeKey } = scope;
|
||||
|
||||
const [summary, setSummary] = useState<VulnerabilitySummary | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
@@ -30,9 +30,7 @@ export default function VulnerabilitiesTab({ scope, timeRange }: { scope: Softwa
|
||||
const run = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const url = routeGlobal
|
||||
? api.metrics.globalVulnerability(days, TOP_N, selectedTenantId || undefined)
|
||||
: api.metrics.vulnerability(days, TOP_N);
|
||||
const url = scopedApi.vulnerability(scope, days, TOP_N);
|
||||
const res = await authenticatedFetch(url, getAccessToken);
|
||||
if (cancelled) return;
|
||||
if (res.ok) {
|
||||
|
||||
@@ -8,7 +8,7 @@ import { ProtectedRoute } from "../../../components/ProtectedRoute";
|
||||
import { useTenant } from "../../../contexts/TenantContext";
|
||||
import { useAuth } from "../../../contexts/AuthContext";
|
||||
import { useNotifications } from "../../../contexts/NotificationContext";
|
||||
import { api } from "@/lib/api";
|
||||
import { scopedApi } from "@/lib/scopedApi";
|
||||
import { authenticatedFetch, TokenExpiredError } from "@/lib/authenticatedFetch";
|
||||
import { getErrorCodeEntry, formatErrorCode } from "@/utils/errorCodeMap";
|
||||
import { useAggregatedAdminScope } from "@/hooks";
|
||||
@@ -231,11 +231,12 @@ function AppDetailContent() {
|
||||
// Global admin tenant scope (aggregated-capable), seeded from the persisted tab scope or a ?tenantId=
|
||||
// deep-link; falls back to the GA's own tenant.
|
||||
const scope = useAggregatedAdminScope({ urlTenantId });
|
||||
const { isGlobalAdmin, selectedTenantId, tenants, scopeInitialized, scopeKey } = scope;
|
||||
const { isGlobalAdmin, tenants, scopeInitialized, scopeKey } = scope;
|
||||
|
||||
// Detail-page endpoint rule: use the /global/ endpoint when viewing a tenant other than the user's own
|
||||
// (including the aggregated "" view), or when the own tenant isn't resolved yet.
|
||||
const useGlobalEndpoint = Boolean(isGlobalAdmin && (selectedTenantId !== tenantId || !tenantId));
|
||||
// Endpoint routing comes from the scope hook's routeGlobal (via scopedApi) — the former
|
||||
// local formula was equivalent except that a GA viewing their OWN tenant used the member
|
||||
// variant; routeGlobal sends them to the /global/ variant with an explicit tenantId, which
|
||||
// returns the same data under the same authorization.
|
||||
|
||||
// Build a tid → friendly name lookup once for the Tenant column.
|
||||
const tenantLookup = useMemo(() => {
|
||||
@@ -315,9 +316,7 @@ function AppDetailContent() {
|
||||
try {
|
||||
setLoading(true);
|
||||
progressBegin();
|
||||
const url = useGlobalEndpoint
|
||||
? api.apps.globalAnalytics(appName, days, selectedTenantId || undefined)
|
||||
: api.apps.analytics(tenantId, appName, days);
|
||||
const url = scopedApi.appAnalytics(scope, appName, days);
|
||||
const response = await authenticatedFetch(url, getAccessToken, {
|
||||
signal: AbortSignal.timeout(APPS_FETCH_TIMEOUT_MS),
|
||||
});
|
||||
@@ -355,9 +354,7 @@ function AppDetailContent() {
|
||||
if (!isGlobalAdmin && !tenantId) return;
|
||||
try {
|
||||
setSessionsLoading(true);
|
||||
const url = useGlobalEndpoint
|
||||
? api.apps.globalSessions(appName, days, status, offset, SESSIONS_PAGE_SIZE, selectedTenantId || undefined)
|
||||
: api.apps.sessions(tenantId, appName, days, status, offset, SESSIONS_PAGE_SIZE);
|
||||
const url = scopedApi.appSessions(scope, appName, days, status, offset, SESSIONS_PAGE_SIZE);
|
||||
const response = await authenticatedFetch(url, getAccessToken, {
|
||||
signal: AbortSignal.timeout(APPS_FETCH_TIMEOUT_MS),
|
||||
});
|
||||
|
||||
@@ -5,7 +5,7 @@ import { TableSkeleton } from '@/components/skeletons/TableSkeleton';
|
||||
import { useAuth } from '../../contexts/AuthContext';
|
||||
import { useNotifications } from '../../contexts/NotificationContext';
|
||||
import { ProtectedRoute } from '../../components/ProtectedRoute';
|
||||
import { api } from '@/lib/api';
|
||||
import { scopedApi } from "@/lib/scopedApi";
|
||||
import { authenticatedFetch, TokenExpiredError } from "@/lib/authenticatedFetch";
|
||||
import { extractContinuation } from "@/lib/paginationLink";
|
||||
import { useAggregatedAdminScope } from "@/hooks";
|
||||
@@ -89,7 +89,7 @@ export default function AuditPage() {
|
||||
// GA/Reader starts in the aggregated "All tenants" view; a delegated ("MSP") admin has no aggregate and
|
||||
// still defaults to its first managed tenant via the normal tenant switcher.
|
||||
const scope = useAggregatedAdminScope({ defaultAggregated: true });
|
||||
const { isGlobalAdmin: crossTenant, routeGlobal, selectedTenantId, scopeInitialized, scopeKey } = scope;
|
||||
const { isGlobalAdmin: crossTenant, routeGlobal, selectedTenantId, effectiveTenantId, scopeInitialized, scopeKey } = scope;
|
||||
|
||||
// The DEFAULT view ("All (excl. deletions)") is resolved server-side: the
|
||||
// backend drops per-session deletion bookkeeping and back-fills the page, so
|
||||
@@ -114,9 +114,7 @@ export default function AuditPage() {
|
||||
// Cross-tenant: globalLogs with the selected tenant ("" → GA aggregate over all tenants; a managed
|
||||
// tenant for a delegated caller, validated server-side). Own-tenant member — including a delegated
|
||||
// caller viewing their HOME tenant (routeGlobal false): the tenant-scoped logs.
|
||||
const endpoint = routeGlobal
|
||||
? api.audit.globalLogs({ ...opts, tenantId: selectedTenantId || undefined })
|
||||
: api.audit.logs(opts);
|
||||
const endpoint = scopedApi.auditLogs({ routeGlobal, selectedTenantId, effectiveTenantId }, opts);
|
||||
const response = await authenticatedFetch(endpoint, getAccessToken);
|
||||
if (!response.ok) {
|
||||
addNotification('error', 'Backend Error', `Failed to load audit logs: ${response.statusText}`, 'audit-fetch-error');
|
||||
@@ -140,7 +138,7 @@ export default function AuditPage() {
|
||||
setLoading(false);
|
||||
setRefreshing(false);
|
||||
}
|
||||
}, [addNotification, dateFromIso, dateToIso, getAccessToken, routeGlobal, selectedTenantId, excludeDeletions]);
|
||||
}, [addNotification, dateFromIso, dateToIso, getAccessToken, routeGlobal, selectedTenantId, effectiveTenantId, excludeDeletions]);
|
||||
|
||||
// Initial / window-change fetch resets pagination state.
|
||||
// fetchPage is intentionally excluded from deps: its identity churns whenever
|
||||
|
||||
@@ -12,6 +12,7 @@ import { isHomeTenantTarget } from "@/utils/homeTenantScope";
|
||||
import { hasTenantReadScope } from "@/lib/tenantScope";
|
||||
import type { NotificationType } from "@/contexts/NotificationContext";
|
||||
import type { Session } from "../types";
|
||||
import type { SignalRMessageName } from "@/lib/signalrMessages";
|
||||
|
||||
const DEFAULT_PAGE_SIZE = 10;
|
||||
const MAX_PAGE_SIZE = 1000;
|
||||
@@ -43,9 +44,9 @@ interface User {
|
||||
|
||||
interface SignalRApi {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
on: (event: string, handler: (...args: any[]) => void) => void;
|
||||
on: (event: SignalRMessageName, handler: (...args: any[]) => void) => void;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
off: (event: string, handler: (...args: any[]) => void) => void;
|
||||
off: (event: SignalRMessageName, handler: (...args: any[]) => void) => void;
|
||||
isConnected: boolean;
|
||||
joinGroup: (group: string) => Promise<void>;
|
||||
leaveGroup: (group: string) => Promise<void>;
|
||||
|
||||
@@ -8,6 +8,7 @@ import { asGuidOrUndefined } from "@/utils/inputValidation";
|
||||
import { boundTenantToDelegatedScope } from "@/utils/delegatedScope";
|
||||
import { isHomeTenantTarget } from "@/utils/homeTenantScope";
|
||||
import type { NotificationType } from "@/contexts/NotificationContext";
|
||||
import type { SignalRMessageName } from "@/lib/signalrMessages";
|
||||
|
||||
export interface DashboardStats {
|
||||
days: number;
|
||||
@@ -32,9 +33,9 @@ type AddNotification = (
|
||||
|
||||
interface SignalRApi {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
on: (event: string, handler: (...args: any[]) => void) => void;
|
||||
on: (event: SignalRMessageName, handler: (...args: any[]) => void) => void;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
off: (event: string, handler: (...args: any[]) => void) => void;
|
||||
off: (event: SignalRMessageName, handler: (...args: any[]) => void) => void;
|
||||
isConnected: boolean;
|
||||
}
|
||||
|
||||
|
||||
@@ -217,13 +217,14 @@ function DiagnosisContent() {
|
||||
|
||||
// Real-time analysis updates
|
||||
useEffect(() => {
|
||||
// eventStream carries { sessionId, tenantId, newEventCount, newRuleResults } — the full
|
||||
// session object was removed from the payload long ago (see useSessionSignalR), so only
|
||||
// the rule-result delta is consumed here.
|
||||
const handleEventStream = (data: {
|
||||
sessionId: string;
|
||||
session: Session;
|
||||
newRuleResults?: RuleResult[];
|
||||
}) => {
|
||||
if (data.sessionId !== sessionIdRef.current) return;
|
||||
if (data.session) setSession(data.session);
|
||||
if (data.newRuleResults?.length) {
|
||||
setAnalysisResults((prev) => {
|
||||
const existingIds = new Set(prev.map((r) => r.ruleId));
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { api } from "@/lib/api";
|
||||
import { scopedApi } from "@/lib/scopedApi";
|
||||
import { useLatest } from "@/hooks/useLatest";
|
||||
import { authenticatedFetch, TokenExpiredError } from "@/lib/authenticatedFetch";
|
||||
import { asGuidOrUndefined } from "@/utils/inputValidation";
|
||||
import type { NotificationType } from "@/contexts/NotificationContext";
|
||||
import type { SignalRMessageName } from "@/lib/signalrMessages";
|
||||
|
||||
// Wire shape of the server-aggregated Fleet Health payload (camelCase of the
|
||||
// backend FleetHealthMetrics DTO). Presentation-only derivations (bar maxima,
|
||||
@@ -70,9 +70,9 @@ type AddNotification = (
|
||||
|
||||
interface SignalRApi {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
on: (event: string, handler: (...args: any[]) => void) => void;
|
||||
on: (event: SignalRMessageName, handler: (...args: any[]) => void) => void;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
off: (event: string, handler: (...args: any[]) => void) => void;
|
||||
off: (event: SignalRMessageName, handler: (...args: any[]) => void) => void;
|
||||
isConnected: boolean;
|
||||
}
|
||||
|
||||
@@ -147,12 +147,15 @@ export function useFleetHealth({
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const url = routeGlobalRef.current
|
||||
? api.metrics.globalFleetHealth(
|
||||
daysRef.current,
|
||||
asGuidOrUndefined(selectedTenantIdRef.current.trim()),
|
||||
)
|
||||
: api.metrics.fleetHealth(daysRef.current);
|
||||
const url = scopedApi.fleetHealth(
|
||||
{
|
||||
routeGlobal: routeGlobalRef.current,
|
||||
selectedTenantId: selectedTenantIdRef.current,
|
||||
// Member path (routeGlobal false) needs no tenant on this endpoint (JWT-scoped).
|
||||
effectiveTenantId: tenantIdRef.current ?? "",
|
||||
},
|
||||
daysRef.current,
|
||||
);
|
||||
|
||||
const response = await authenticatedFetch(url, getAccessToken);
|
||||
|
||||
@@ -188,7 +191,7 @@ export function useFleetHealth({
|
||||
} finally {
|
||||
if (myGen === fetchGenRef.current) setLoading(false);
|
||||
}
|
||||
}, [getAccessToken, addNotification, routeGlobalRef, selectedTenantIdRef, daysRef]);
|
||||
}, [getAccessToken, addNotification, routeGlobalRef, selectedTenantIdRef, tenantIdRef, daysRef]);
|
||||
|
||||
const refresh = useCallback(() => {
|
||||
if (debounceTimerRef.current) {
|
||||
|
||||
@@ -9,7 +9,8 @@ import { useSignalR } from "../../contexts/SignalRContext";
|
||||
import { useTenant } from "../../contexts/TenantContext";
|
||||
import { useAuth } from "../../contexts/AuthContext";
|
||||
import { useNotifications } from "../../contexts/NotificationContext";
|
||||
import { api } from "@/lib/api";
|
||||
import { scopedApi } from "@/lib/scopedApi";
|
||||
import { fetchJson } from "@/lib/scopedFetch";
|
||||
import { authenticatedFetch, TokenExpiredError } from "@/lib/authenticatedFetch";
|
||||
import FleetStatCard from "./components/FleetStatCard";
|
||||
import TimeAttributionSection, { TimeAttributionResponseDto } from "./components/TimeAttributionSection";
|
||||
@@ -81,9 +82,7 @@ export default function FleetHealthPage() {
|
||||
const fetchAppMetrics = async (range: "7d" | "30d" | "90d" = timeRange) => {
|
||||
try {
|
||||
const d = range === "7d" ? 7 : range === "30d" ? 30 : 90;
|
||||
const endpoint = routeGlobal
|
||||
? api.metrics.globalApp(d, selectedTenantId || undefined)
|
||||
: api.metrics.app(tenantId, d);
|
||||
const endpoint = scopedApi.appMetrics(scope, d);
|
||||
const response = await authenticatedFetch(endpoint, getAccessToken);
|
||||
if (response.ok) {
|
||||
const json = await response.json();
|
||||
@@ -119,12 +118,7 @@ export default function FleetHealthPage() {
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
try {
|
||||
const endpoint = routeGlobal
|
||||
? api.metrics.globalTimeAttribution(selectedTenantId || undefined)
|
||||
: api.metrics.timeAttribution();
|
||||
const response = await authenticatedFetch(endpoint, getAccessToken);
|
||||
if (!response.ok) return;
|
||||
const json = await response.json();
|
||||
const json = await fetchJson<TimeAttributionResponseDto>(scopedApi.timeAttribution(scope), getAccessToken);
|
||||
if (!cancelled) setTimeAttribution(json);
|
||||
} catch {
|
||||
// fail-soft: section stays hidden
|
||||
@@ -141,12 +135,7 @@ export default function FleetHealthPage() {
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
try {
|
||||
const endpoint = routeGlobal
|
||||
? api.metrics.globalDeviceJourneys(days, selectedTenantId || undefined)
|
||||
: api.metrics.deviceJourneys(days);
|
||||
const response = await authenticatedFetch(endpoint, getAccessToken);
|
||||
if (!response.ok) return;
|
||||
const json = await response.json();
|
||||
const json = await fetchJson<DeviceJourneyResponseDto>(scopedApi.deviceJourneys(scope, days), getAccessToken);
|
||||
if (!cancelled) setDeviceJourneys(json);
|
||||
} catch {
|
||||
// fail-soft: section stays hidden
|
||||
|
||||
@@ -5,9 +5,8 @@ import { useRouter } from "next/navigation";
|
||||
import dynamic from "next/dynamic";
|
||||
import { ProtectedRoute } from "../../components/ProtectedRoute";
|
||||
import TruncatedLabel from "@/components/TruncatedLabel";
|
||||
import { useTenant } from "../../contexts/TenantContext";
|
||||
import { useAuth } from "../../contexts/AuthContext";
|
||||
import { api } from "@/lib/api";
|
||||
import { scopedApi } from "@/lib/scopedApi";
|
||||
import { authenticatedFetch, TokenExpiredError } from "@/lib/authenticatedFetch";
|
||||
import { useAggregatedAdminScope } from "@/hooks";
|
||||
import { useFetchProgress } from "@/hooks/useFetchProgress";
|
||||
@@ -146,13 +145,12 @@ export default function GeographicPerformancePage() {
|
||||
|
||||
const isTimeRangeMount = useRef(true);
|
||||
|
||||
const { tenantId } = useTenant();
|
||||
const { getAccessToken } = useAuth();
|
||||
|
||||
// Global admin tenant scope (aggregated-capable): tenant list, selection ("" = all tenants),
|
||||
// and scope flags. Default selection is the GA's own tenant.
|
||||
const scope = useAggregatedAdminScope();
|
||||
const { routeGlobal, selectedTenantId, isAggregatedGlobalView, scopeInitialized, scopeKey } = scope;
|
||||
const { routeGlobal, selectedTenantId, effectiveTenantId, isAggregatedGlobalView, scopeInitialized, scopeKey } = scope;
|
||||
|
||||
const progress = useFetchProgress("geoPerf.lastFetchMs");
|
||||
const { begin: progressBegin, finish: progressFinish } = progress;
|
||||
@@ -162,9 +160,7 @@ export default function GeographicPerformancePage() {
|
||||
try {
|
||||
progressBegin();
|
||||
const days = range === "7d" ? 7 : range === "30d" ? 30 : 90;
|
||||
const endpoint = routeGlobal
|
||||
? api.metrics.globalGeographic(days, group, selectedTenantId || undefined)
|
||||
: api.metrics.geographic(tenantId, days, group);
|
||||
const endpoint = scopedApi.geographic({ routeGlobal, selectedTenantId, effectiveTenantId }, days, group);
|
||||
const response = await authenticatedFetch(endpoint, getAccessToken, {
|
||||
signal: AbortSignal.timeout(GEO_FETCH_TIMEOUT_MS),
|
||||
});
|
||||
@@ -183,7 +179,7 @@ export default function GeographicPerformancePage() {
|
||||
progressFinish(succeeded);
|
||||
setLoading(false);
|
||||
}
|
||||
}, [routeGlobal, selectedTenantId, tenantId, getAccessToken, timeRange, groupBy, progressBegin, progressFinish]);
|
||||
}, [routeGlobal, selectedTenantId, effectiveTenantId, getAccessToken, timeRange, groupBy, progressBegin, progressFinish]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!scopeInitialized) return;
|
||||
|
||||
@@ -41,6 +41,10 @@ export default function HealthCheckPage() {
|
||||
// dashboard on it. The card renders a "checking" state immediately and updates in place.
|
||||
const [mcpCheck, setMcpCheck] = useState<HealthCheck | null>(null);
|
||||
const [mcpLoading, setMcpLoading] = useState(false);
|
||||
// Portal build stamp: /version.json is a static file from this same origin (generated at
|
||||
// build time, Cache-Control: no-store) — plain fetch, no auth. Card stays hidden when the
|
||||
// file is absent (e.g. a dev server without the prebuild step).
|
||||
const [webBuild, setWebBuild] = useState<{ commit?: string; buildUtc?: string } | null>(null);
|
||||
|
||||
const performHealthCheck = useCallback(async () => {
|
||||
setLoading(true);
|
||||
@@ -105,6 +109,16 @@ export default function HealthCheckPage() {
|
||||
}
|
||||
}, [getAccessToken]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!user?.isGlobalAdmin) return;
|
||||
let cancelled = false;
|
||||
fetch("/version.json", { cache: "no-store" })
|
||||
.then((r) => (r.ok ? r.json() : null))
|
||||
.then((d) => { if (!cancelled && d?.commit) setWebBuild(d); })
|
||||
.catch(() => { /* card simply stays hidden */ });
|
||||
return () => { cancelled = true; };
|
||||
}, [user?.isGlobalAdmin]);
|
||||
|
||||
// Auto-run health checks on page load. The two probes are fired independently so the
|
||||
// fast backend checks render immediately while the MCP probe (possible cold start) catches up.
|
||||
useEffect(() => {
|
||||
@@ -275,6 +289,44 @@ export default function HealthCheckPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Portal Build (Global Admin only) */}
|
||||
{user?.isGlobalAdmin && webBuild && (
|
||||
<div className="mb-6">
|
||||
<h2 className="text-sm font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider mb-3">Portal Build</h2>
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow border-l-4 border-indigo-500">
|
||||
<div className="p-4 grid grid-cols-1 sm:grid-cols-3 gap-4 text-sm">
|
||||
<div>
|
||||
<dt className="text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase">Component</dt>
|
||||
<dd className="mt-1 font-mono text-gray-900 dark:text-gray-100">web</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase">Commit</dt>
|
||||
<dd className="mt-1 font-mono text-gray-900 dark:text-gray-100">
|
||||
{webBuild.commit && webBuild.commit !== "unknown" ? (
|
||||
<a
|
||||
href={`https://github.com/okieselbach/AutopilotMonitor/commit/${webBuild.commit}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-indigo-600 dark:text-indigo-400 hover:underline"
|
||||
>
|
||||
{webBuild.commit}
|
||||
</a>
|
||||
) : (
|
||||
<span className="text-gray-400">unknown</span>
|
||||
)}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase">Built</dt>
|
||||
<dd className="mt-1 text-gray-900 dark:text-gray-100">
|
||||
{webBuild.buildUtc ? new Date(webBuild.buildUtc).toLocaleString() : '—'}
|
||||
</dd>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Real-Time Connection Status */}
|
||||
<div className="mb-6">
|
||||
<h2 className="text-sm font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider mb-3">Real-Time Connection</h2>
|
||||
|
||||
@@ -2,12 +2,13 @@
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { Session } from "@/types";
|
||||
import type { SignalRMessageName } from "@/lib/signalrMessages";
|
||||
|
||||
interface SignalRApi {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
on: (event: string, handler: (...args: any[]) => void) => void;
|
||||
on: (event: SignalRMessageName, handler: (...args: any[]) => void) => void;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
off: (event: string, handler: (...args: any[]) => void) => void;
|
||||
off: (event: SignalRMessageName, handler: (...args: any[]) => void) => void;
|
||||
isConnected: boolean;
|
||||
joinGroup: (group: string) => Promise<void>;
|
||||
leaveGroup: (group: string) => Promise<void>;
|
||||
|
||||
@@ -3,12 +3,13 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import { Session, RuleResult } from "@/types";
|
||||
import { isTerminalStatus } from "@/utils/sessionStatus";
|
||||
import type { SignalRMessageName } from "@/lib/signalrMessages";
|
||||
|
||||
interface SignalRApi {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
on: (event: string, handler: (...args: any[]) => void) => void;
|
||||
on: (event: SignalRMessageName, handler: (...args: any[]) => void) => void;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
off: (event: string, handler: (...args: any[]) => void) => void;
|
||||
off: (event: SignalRMessageName, handler: (...args: any[]) => void) => void;
|
||||
isConnected: boolean;
|
||||
joinGroup: (group: string) => Promise<void>;
|
||||
leaveGroup: (group: string) => Promise<void>;
|
||||
|
||||
@@ -28,6 +28,7 @@ const validationEnabledSuffix = (t: ValidationTrigger) =>
|
||||
import { parseSasExpiry } from "./components/DiagnosticsSection";
|
||||
import { COMMUNITY_DEFAULT, parseEditionInfo, type EditionInfo } from "@/lib/edition";
|
||||
import { TenantConfiguration, TenantAdmin, DiagnosticsLogPath, NotificationChannel, LEGACY_CHANNEL_ID } from "./types";
|
||||
import { SECTION_FIELD_MAP, type SectionFieldSpec, type SettingsSectionName } from "./sectionFieldMap";
|
||||
import { type BootstrapSessionItem } from "./components/BootstrapSessionsSection";
|
||||
|
||||
/**
|
||||
@@ -717,9 +718,9 @@ export function TenantConfigProvider({ children }: { children: React.ReactNode }
|
||||
// -----------------------------------------------------------------------
|
||||
// Save configuration (shared by all sections)
|
||||
// -----------------------------------------------------------------------
|
||||
const saveConfiguration = useCallback(async (sectionName: string, overrides?: { validateAutopilotDevice?: boolean; validateCorporateIdentifier?: boolean; validateDeviceAssociation?: boolean; validateCloudPcDevice?: boolean; unrestrictedMode?: boolean }): Promise<boolean> => {
|
||||
const saveConfiguration = useCallback(async (sectionName: SettingsSectionName, overrides?: { validateAutopilotDevice?: boolean; validateCorporateIdentifier?: boolean; validateDeviceAssociation?: boolean; validateCloudPcDevice?: boolean; unrestrictedMode?: boolean }): Promise<boolean> => {
|
||||
// Read-only viewers (Operators) have no save affordances; this guard covers any path
|
||||
// that still reaches a save (the backend would 403 the PUT regardless).
|
||||
// that still reaches a save (the backend would 403 the PATCH regardless).
|
||||
if (!tenantId || !config || !canEditConfig) return false;
|
||||
|
||||
try {
|
||||
@@ -794,26 +795,49 @@ export function TenantConfigProvider({ children }: { children: React.ReactNode }
|
||||
unrestrictedMode: unrestrictedModeValue,
|
||||
};
|
||||
|
||||
const response = await authenticatedFetch(api.config.tenant(tenantId), getAccessToken, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(updatedConfig),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
throw new Error(errorData.message || errorData.error || `Failed to save configuration: ${response.statusText}`);
|
||||
// Per-section PATCH: send ONLY this section's fields (plus documented write-throughs)
|
||||
// that actually differ from the loaded config. The other ~90 fields never round-trip,
|
||||
// so a stale read cannot revert unrelated fields (the 2026-07-31 incident class), and
|
||||
// GA-only toggles a tenant admin cannot edit are simply never in the payload.
|
||||
const spec: SectionFieldSpec = SECTION_FIELD_MAP[sectionName];
|
||||
const patchFields: Record<string, unknown> = {};
|
||||
for (const field of [...spec.fields, ...(spec.alsoWrites ?? [])]) {
|
||||
// undefined and null both mean "cleared" on the wire; PATCH expresses a clear as
|
||||
// an explicit JSON null (an omitted key would leave the stored value untouched).
|
||||
const next = (updatedConfig as unknown as Record<string, unknown>)[field] ?? null;
|
||||
const prev = (config as unknown as Record<string, unknown>)[field] ?? null;
|
||||
if (JSON.stringify(next) !== JSON.stringify(prev)) {
|
||||
patchFields[field] = next;
|
||||
}
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
setConfig(result.config);
|
||||
// Sync form state variables with the server response
|
||||
setValidateAutopilotDevice(result.config.validateAutopilotDevice);
|
||||
setValidateCorporateIdentifier(result.config.validateCorporateIdentifier ?? false);
|
||||
setValidateDeviceAssociation(result.config.validateDeviceAssociation ?? false);
|
||||
setValidateCloudPcDevice(result.config.validateCloudPcDevice ?? false);
|
||||
setUnrestrictedMode(result.config.unrestrictedMode ?? false);
|
||||
trackEvent("settings_saved", { section: sectionName });
|
||||
if (Object.keys(patchFields).length > 0) {
|
||||
const response = await authenticatedFetch(api.config.fields(tenantId), getAccessToken, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ fields: patchFields, reason: `settings:${sectionName}` }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
throw new Error(errorData.message || errorData.error || `Failed to save configuration: ${response.statusText}`);
|
||||
}
|
||||
|
||||
// The PATCH response carries applied field names + masked diff, not the config —
|
||||
// the backend verified exactly these fields changed, so merge them locally.
|
||||
await response.json().catch(() => ({}));
|
||||
setConfig({ ...config, ...patchFields } as TenantConfiguration);
|
||||
}
|
||||
|
||||
// Sync the gate-relevant form state with what is now persisted (merge of loaded
|
||||
// config + this patch) — mirrors the old PUT-response sync.
|
||||
const persisted = { ...config, ...patchFields } as TenantConfiguration;
|
||||
setValidateAutopilotDevice(persisted.validateAutopilotDevice);
|
||||
setValidateCorporateIdentifier(persisted.validateCorporateIdentifier ?? false);
|
||||
setValidateDeviceAssociation(persisted.validateDeviceAssociation ?? false);
|
||||
setValidateCloudPcDevice(persisted.validateCloudPcDevice ?? false);
|
||||
setUnrestrictedMode(persisted.unrestrictedMode ?? false);
|
||||
trackEvent("settings_saved", { section: sectionName, fieldCount: Object.keys(patchFields).length });
|
||||
setSuccessMessage("Configuration saved successfully!");
|
||||
setTimeout(() => setSuccessMessage(null), 3000);
|
||||
return true;
|
||||
@@ -1218,7 +1242,8 @@ export function TenantConfigProvider({ children }: { children: React.ReactNode }
|
||||
setKeepLogFile(config.keepLogFile ?? false);
|
||||
setRebootOnComplete(config.rebootOnComplete ?? false);
|
||||
setRebootDelaySeconds(config.rebootDelaySeconds ?? 10);
|
||||
setContactEmail(config.contactEmail ?? "");
|
||||
// contactEmail deliberately NOT reset here — it is owned by the Contact section
|
||||
// (resetting it from the Agent Settings card silently discarded unsaved Contact edits).
|
||||
setEnableGeoLocation(config.enableGeoLocation ?? true);
|
||||
setEnableTimezoneAutoSet(config.enableTimezoneAutoSet ?? false);
|
||||
setEnableImeMatchLog(config.enableImeMatchLog ?? false);
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
/**
|
||||
* Parity pins for the Settings per-section PATCH map (SECTION_FIELD_MAP).
|
||||
* TenantConfigContext builds every save payload from this map, so the map IS the write
|
||||
* surface: a field missing here silently stops being saved, a duplicated field lets two
|
||||
* sections fight over one value. Both directions are pinned against test-local lists
|
||||
* (never derived from the map itself — see tasks/lessons.md on circular parity fixtures).
|
||||
*/
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { SHARED_MANIFEST } from "@/utils/shared-manifests.generated";
|
||||
import { SECTION_FIELD_MAP } from "../sectionFieldMap";
|
||||
|
||||
const sections = Object.entries(SECTION_FIELD_MAP) as Array<
|
||||
[string, { fields: readonly string[]; alsoWrites?: readonly string[] }]
|
||||
>;
|
||||
|
||||
/**
|
||||
* Test-local, independent list of every field the Settings UI edits (one owner section
|
||||
* each). Update it together with SECTION_FIELD_MAP when a section gains/loses a field —
|
||||
* that is the point: removing a field from the map without noticing fails here.
|
||||
*/
|
||||
const EXPECTED_OWNED_FIELDS = [
|
||||
// hardwareWhitelist
|
||||
"manufacturerWhitelist", "modelWhitelist", "webhookNotifyOnHardwareRejection",
|
||||
// autopilotValidation
|
||||
"validateAutopilotDevice", "validateCorporateIdentifier", "validateDeviceAssociation", "validateCloudPcDevice",
|
||||
// notifications
|
||||
"notificationChannelsJson",
|
||||
// slaTargets
|
||||
"slaTargetSuccessRate", "slaTargetMaxDurationMinutes", "slaTargetAppInstallSuccessRate",
|
||||
"slaNotifyOnSuccessRateBreach", "slaSuccessRateNotifyThreshold", "slaNotifyOnDurationBreach",
|
||||
"slaNotifyOnAppInstallBreach", "slaNotifyOnConsecutiveFailures", "slaConsecutiveFailureThreshold",
|
||||
// contact
|
||||
"contactEmail",
|
||||
// agentSettings
|
||||
"enablePerformanceCollector", "performanceCollectorIntervalSeconds", "helloWaitTimeoutSeconds",
|
||||
"selfDestructOnComplete", "keepLogFile", "rebootOnComplete", "rebootDelaySeconds",
|
||||
"enableGeoLocation", "enableTimezoneAutoSet", "enableImeMatchLog", "enableGatherRuleDebugLog",
|
||||
"logLevel", "showScriptOutput", "showEnrollmentSummary", "enrollmentSummaryTimeoutSeconds",
|
||||
"enrollmentSummaryBrandingImageUrl", "enrollmentSummaryLaunchRetrySeconds",
|
||||
// agentAnalyzers
|
||||
"enableLocalAdminAnalyzer", "localAdminAllowedAccountsJson", "enableSoftwareInventoryAnalyzer",
|
||||
"enableIntegrityBypassAnalyzer", "enableRealmJoinWatcher", "keepAwakeDuringUserEsp",
|
||||
"enableConsoleBypassDetection",
|
||||
// diagnostics
|
||||
"diagnosticsBlobSasUrl", "diagnosticsUploadMode", "diagnosticsUploadDestination", "diagnosticsLogPathsJson",
|
||||
// unrestrictedMode
|
||||
"unrestrictedMode",
|
||||
// dataManagement
|
||||
"dataRetentionDays", "sessionTimeoutHours",
|
||||
].sort();
|
||||
|
||||
/**
|
||||
* Wire names the backend's patch endpoint denies for every caller (BaseDeniedFields in
|
||||
* TenantConfigPatchService, camelCased). Independent copy: a section must never own one —
|
||||
* the save would 400 on the first differing value.
|
||||
*/
|
||||
const SERVER_DENIED_FIELDS = new Set([
|
||||
"tenantId", "domainName", "partitionKey", "rowKey", "timestamp", "eTag",
|
||||
"lastUpdated", "updatedBy", "onboardedAt", "onboardedBy",
|
||||
"homedAppClientId", "lastAuthClientId", "lastAuthClientIdSince",
|
||||
"planTier", "trialExpiresUtc", "trialStartedUtc", "trialConsumed", "trialGrantedBy",
|
||||
]);
|
||||
|
||||
describe("SECTION_FIELD_MAP parity", () => {
|
||||
const manifestFields = new Set<string>(SHARED_MANIFEST.tenantConfiguration.fields);
|
||||
|
||||
it("every mapped field (owned and alsoWrites) exists on the backend model", () => {
|
||||
for (const [section, spec] of sections) {
|
||||
for (const field of [...spec.fields, ...(spec.alsoWrites ?? [])]) {
|
||||
expect(manifestFields, `${section}: unknown field '${field}'`).toContain(field);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("every field is owned by exactly one section", () => {
|
||||
const seen = new Map<string, string>();
|
||||
for (const [section, spec] of sections) {
|
||||
for (const field of spec.fields) {
|
||||
expect(seen.has(field), `'${field}' owned by both '${seen.get(field)}' and '${section}'`).toBe(false);
|
||||
seen.set(field, section);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("owned fields are exactly the expected Settings write surface", () => {
|
||||
const owned = sections.flatMap(([, spec]) => [...spec.fields]).sort();
|
||||
expect(owned).toEqual(EXPECTED_OWNED_FIELDS);
|
||||
});
|
||||
|
||||
it("alsoWrites entries never overlap the same section's owned fields", () => {
|
||||
for (const [section, spec] of sections) {
|
||||
for (const field of spec.alsoWrites ?? []) {
|
||||
expect(spec.fields, `${section}: '${field}' is both owned and alsoWrites`).not.toContain(field);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("no section touches a server-denied field", () => {
|
||||
for (const [section, spec] of sections) {
|
||||
for (const field of [...spec.fields, ...(spec.alsoWrites ?? [])]) {
|
||||
expect(SERVER_DENIED_FIELDS.has(field), `${section}: '${field}' is patch-denied server-side`).toBe(false);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("no section owns a GA-only field a tenant admin could not save", () => {
|
||||
// GaOnlyFields (TenantConfigPatchService), camelCased — independent copy. Exceptions
|
||||
// are deliberate: validateDeviceAssociation is GA-gated but lives in the autopilot
|
||||
// section whose GA-only toggles are hidden for non-GA users, so a tenant admin's
|
||||
// diff never contains it.
|
||||
const gaOnly = new Set([
|
||||
"disabled", "disabledReason", "disabledUntil",
|
||||
"allowInsecureAgentRequests", "bootstrapTokenEnabled",
|
||||
"unrestrictedModeEnabled", "entraAppRolesEnabled",
|
||||
"enableEspContinueAnywayObservation",
|
||||
"customRateLimitRequestsPerMinute", "customUserRateLimitRequestsPerMinute",
|
||||
"maxNdjsonPayloadSizeMB",
|
||||
]);
|
||||
for (const [section, spec] of sections) {
|
||||
for (const field of [...spec.fields, ...(spec.alsoWrites ?? [])]) {
|
||||
expect(gaOnly.has(field), `${section}: '${field}' is GA-only — it must not be part of a section save`).toBe(false);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,117 @@
|
||||
import { SHARED_MANIFEST } from "@/utils/shared-manifests.generated";
|
||||
|
||||
/**
|
||||
* Wire (camelCase) name of a TenantConfiguration field, typed against the generated shared
|
||||
* manifest — a typo or a field the backend model no longer has fails tsc.
|
||||
*/
|
||||
export type TenantConfigFieldName =
|
||||
(typeof SHARED_MANIFEST.tenantConfiguration.fields)[number];
|
||||
|
||||
export interface SectionFieldSpec {
|
||||
/**
|
||||
* Fields this Settings section OWNS (its form edits them directly). Ownership is exclusive:
|
||||
* every field appears in at most one section's `fields` — pinned by sectionFieldMap.test.ts.
|
||||
*/
|
||||
fields: readonly TenantConfigFieldName[];
|
||||
/**
|
||||
* Deliberate cross-section write-throughs: fields this section's save also patches even
|
||||
* though it does not own them. Kept separate so the ownership test stays strict and every
|
||||
* exception is visible here with its reason.
|
||||
*/
|
||||
alsoWrites?: readonly TenantConfigFieldName[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Section → exact TenantConfiguration fields each Settings section saves. TenantConfigContext
|
||||
* builds its per-section PATCH from this map: only the section's fields (plus alsoWrites) that
|
||||
* actually DIFFER from the loaded config are sent — the other ~90 fields never round-trip, so
|
||||
* a stale read can no longer revert unrelated fields (the 2026-07-31 incident class).
|
||||
*/
|
||||
export const SECTION_FIELD_MAP = {
|
||||
hardwareWhitelist: {
|
||||
fields: ["manufacturerWhitelist", "modelWhitelist", "webhookNotifyOnHardwareRejection"],
|
||||
// The hardware-rejection toggle is a convenience view over the per-channel flags: it
|
||||
// writes through into the channels JSON (owned by `notifications`) by design.
|
||||
alsoWrites: ["notificationChannelsJson"],
|
||||
},
|
||||
autopilotValidation: {
|
||||
fields: [
|
||||
"validateAutopilotDevice",
|
||||
"validateCorporateIdentifier",
|
||||
"validateDeviceAssociation",
|
||||
"validateCloudPcDevice",
|
||||
],
|
||||
},
|
||||
notifications: {
|
||||
fields: ["notificationChannelsJson"],
|
||||
// Channels are authoritative; saving them clears the legacy single-webhook fields so
|
||||
// deleting the last channel cannot resurrect a zombie webhook via legacy synthesis.
|
||||
// No section owns these legacy fields — they are only ever cleared here.
|
||||
alsoWrites: ["webhookProviderType", "webhookUrl", "webhookCustomHeadersJson", "teamsWebhookUrl"],
|
||||
},
|
||||
slaTargets: {
|
||||
fields: [
|
||||
"slaTargetSuccessRate",
|
||||
"slaTargetMaxDurationMinutes",
|
||||
"slaTargetAppInstallSuccessRate",
|
||||
"slaNotifyOnSuccessRateBreach",
|
||||
"slaSuccessRateNotifyThreshold",
|
||||
"slaNotifyOnDurationBreach",
|
||||
"slaNotifyOnAppInstallBreach",
|
||||
"slaNotifyOnConsecutiveFailures",
|
||||
"slaConsecutiveFailureThreshold",
|
||||
],
|
||||
},
|
||||
contact: {
|
||||
fields: ["contactEmail"],
|
||||
},
|
||||
agentSettings: {
|
||||
fields: [
|
||||
"enablePerformanceCollector",
|
||||
"performanceCollectorIntervalSeconds",
|
||||
"helloWaitTimeoutSeconds",
|
||||
"selfDestructOnComplete",
|
||||
"keepLogFile",
|
||||
"rebootOnComplete",
|
||||
"rebootDelaySeconds",
|
||||
"enableGeoLocation",
|
||||
"enableTimezoneAutoSet",
|
||||
"enableImeMatchLog",
|
||||
"enableGatherRuleDebugLog",
|
||||
"logLevel",
|
||||
"showScriptOutput",
|
||||
"showEnrollmentSummary",
|
||||
"enrollmentSummaryTimeoutSeconds",
|
||||
"enrollmentSummaryBrandingImageUrl",
|
||||
"enrollmentSummaryLaunchRetrySeconds",
|
||||
],
|
||||
},
|
||||
agentAnalyzers: {
|
||||
fields: [
|
||||
"enableLocalAdminAnalyzer",
|
||||
"localAdminAllowedAccountsJson",
|
||||
"enableSoftwareInventoryAnalyzer",
|
||||
"enableIntegrityBypassAnalyzer",
|
||||
"enableRealmJoinWatcher",
|
||||
"keepAwakeDuringUserEsp",
|
||||
"enableConsoleBypassDetection",
|
||||
],
|
||||
},
|
||||
diagnostics: {
|
||||
fields: [
|
||||
"diagnosticsBlobSasUrl",
|
||||
"diagnosticsUploadMode",
|
||||
"diagnosticsUploadDestination",
|
||||
"diagnosticsLogPathsJson",
|
||||
],
|
||||
},
|
||||
unrestrictedMode: {
|
||||
fields: ["unrestrictedMode"],
|
||||
},
|
||||
dataManagement: {
|
||||
fields: ["dataRetentionDays", "sessionTimeoutHours"],
|
||||
},
|
||||
} as const satisfies Record<string, SectionFieldSpec>;
|
||||
|
||||
/** Save-label union — saveConfiguration only accepts these, so a section typo fails tsc. */
|
||||
export type SettingsSectionName = keyof typeof SECTION_FIELD_MAP;
|
||||
@@ -7,7 +7,8 @@ import { useAuth } from "../contexts/AuthContext";
|
||||
import { useSidebar, PageSectionItem } from "../contexts/SidebarContext";
|
||||
import { CollapseState } from "../hooks/useSidebarState";
|
||||
import { DefaultSectionIcon, BookOpenIcon, InformationCircleIcon, DocumentTextIcon, ShieldCheckIcon } from "../lib/sidebarIcons";
|
||||
import { DASHBOARD_ITEM, NAV_GROUPS, EXPANDABLE_NAV_GROUPS, REGULAR_USER_ITEMS, NavItem, NavGroup, ExpandableNavGroup } from "../lib/globalNavConfig";
|
||||
import { DASHBOARD_ITEM, NAV_GROUPS, EXPANDABLE_NAV_GROUPS, REGULAR_USER_ITEMS, NavItem } from "../lib/globalNavConfig";
|
||||
import { deriveNavFlags, filterExpandableNavGroups, isNavGroupVisible } from "../lib/navVisibility";
|
||||
import { useAdminMode } from "../hooks/useAdminMode";
|
||||
import { useMediaQuery } from "../hooks/useMediaQuery";
|
||||
import { DOCS_URL } from "@/utils/config";
|
||||
@@ -168,78 +169,16 @@ export function GlobalSidebar({ children }: { children: ReactNode }) {
|
||||
}, []);
|
||||
|
||||
// --- Visibility filtering ---
|
||||
const isTenantAdmin = user?.isTenantAdmin ?? false;
|
||||
const isOperator = user?.role === "Operator";
|
||||
const isViewer = user?.role === "Viewer";
|
||||
// Any resolved tenant role — Admin, Operator, or read-only Viewer. The Viewer sees the same
|
||||
// monitoring/rules/operations/configuration nav; read-only is enforced inside the pages.
|
||||
const isTenantMember = isTenantAdmin || isOperator || isViewer;
|
||||
const isGlobalAdmin = user?.isGlobalAdmin ?? false;
|
||||
const isDelegated = user?.isDelegated ?? false;
|
||||
// Pure logic in lib/navVisibility.ts (pinned by the roles × nav-config matrix test).
|
||||
const navFlags = useMemo(
|
||||
() => deriveNavFlags({ user, hasGlobalScope, hasFleetScope, globalAdminMode }),
|
||||
[user, hasGlobalScope, hasFleetScope, globalAdminMode],
|
||||
);
|
||||
|
||||
const isGroupVisible = (group: NavGroup | ExpandableNavGroup): boolean => {
|
||||
switch (group.visibility) {
|
||||
case "all": return true;
|
||||
// A GlobalReader is "GA minus writes" — it must see the SAME sidebar as a real GA. The standard
|
||||
// monitoring/rules/operations/configuration groups are normally tenant-member scope; open them to any
|
||||
// platform scope so a pure GlobalReader (no own-tenant role) gets Fleet Health, Usage Metrics, SLA,
|
||||
// etc. Read-only is enforced inside the pages (useCanMutatePlatform + backend), not by hiding nav.
|
||||
case "tenantMember": return isTenantMember || hasGlobalScope;
|
||||
// The (purple) Global Admin section is gated on the Global View toggle — IDENTICAL to a real GA:
|
||||
// toggle off → hidden, toggle on → shown. Item-level globalAdminOnly entries (Settings/Ops/Software)
|
||||
// still drop out for a read-only reader via the item filter below.
|
||||
case "globalAdmin": return hasGlobalScope && globalAdminMode;
|
||||
// Fleet (MSP) nav: shown to a delegated admin who does NOT have full platform scope. A GA/Reader
|
||||
// gets the standard monitoring groups above instead, so it stays hidden for them (no duplication).
|
||||
case "fleet": return isDelegated && !hasGlobalScope;
|
||||
default: return false;
|
||||
}
|
||||
};
|
||||
|
||||
// Visible expandable groups (with item-level filtering for feature-gated items)
|
||||
const hasMcpAccess = user?.hasMcpAccess ?? false;
|
||||
const isAdminLike = isTenantAdmin || isGlobalAdmin;
|
||||
const canManageBootstrapTokens = user?.canManageBootstrapTokens ?? false;
|
||||
const bootstrapTokenEnabled = user?.bootstrapTokenEnabled ?? false;
|
||||
const unrestrictedModeEnabled = user?.unrestrictedModeEnabled ?? false;
|
||||
const visibleExpandableGroups = useMemo(() => {
|
||||
return EXPANDABLE_NAV_GROUPS
|
||||
.filter(isGroupVisible)
|
||||
.map((group) => {
|
||||
// Filter out MCP item if user doesn't have MCP access
|
||||
const filteredItems = group.items
|
||||
.filter((item) => {
|
||||
if (item.id === "cfg-reporting") return hasMcpAccess;
|
||||
// Platform-settings/mutation sub-sections are real-GA-only: hide from a read-only
|
||||
// Global Reader (who reaches the group via hasGlobalScope visibility).
|
||||
if ("visibility" in item && item.visibility === "globalAdminOnly") return isGlobalAdmin;
|
||||
return true;
|
||||
})
|
||||
.map((item) => {
|
||||
// Sub-item gating for feature-flagged entries (tenant feature flags)
|
||||
const filteredSubs = item.items.filter((sub) => {
|
||||
if (sub.id === "cfg-bootstrap-sessions") {
|
||||
return bootstrapTokenEnabled && (isAdminLike || canManageBootstrapTokens);
|
||||
}
|
||||
if (sub.id === "cfg-agent-unrestricted") {
|
||||
return isAdminLike && unrestrictedModeEnabled;
|
||||
}
|
||||
// Tenant-admin-only sub-sections: Operators and Viewers (read-only settings
|
||||
// viewers) don't see them — matches the in-page "tenant administrators only" gates. A platform
|
||||
// scope (GA / read-only GlobalReader) keeps the full GA-identical sidebar.
|
||||
if (sub.id === "cfg-autopilot" || sub.id === "cfg-access-mgmt" || sub.id === "cfg-offboarding") {
|
||||
return isAdminLike || hasGlobalScope;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
return { ...item, items: filteredSubs };
|
||||
})
|
||||
// Drop items whose sub-items have all been filtered out
|
||||
.filter((item) => item.items.length > 0);
|
||||
return { ...group, items: filteredItems };
|
||||
});
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [isTenantMember, hasGlobalScope, isGlobalAdmin, globalAdminMode, hasMcpAccess, isAdminLike, canManageBootstrapTokens, bootstrapTokenEnabled, unrestrictedModeEnabled]);
|
||||
const visibleExpandableGroups = useMemo(
|
||||
() => filterExpandableNavGroups(EXPANDABLE_NAV_GROUPS, navFlags),
|
||||
[navFlags],
|
||||
);
|
||||
|
||||
// Auto-expand the group containing the current pathname — adjust-during-render on
|
||||
// (pathname, visibleExpandableGroups). The groups identity matters too: on a hard
|
||||
@@ -409,17 +348,10 @@ export function GlobalSidebar({ children }: { children: ReactNode }) {
|
||||
};
|
||||
|
||||
// --- Build the nav content (shared between desktop and mobile) ---
|
||||
const visibleGroups = NAV_GROUPS.filter(isGroupVisible);
|
||||
const visibleGroups = NAV_GROUPS.filter((group) => isNavGroupVisible(group, navFlags));
|
||||
const hasPageSections = pageSections.length > 0;
|
||||
|
||||
// Regular users see minimal nav. A read-only Global Reader has platform scope, and a delegated MSP admin
|
||||
// has fleet scope → both get the (group-filtered) nav rather than the minimal regular-user list.
|
||||
const isRegularUser = !isTenantMember && !hasFleetScope;
|
||||
// The Dashboard is the cross-tenant session browser. Own-tenant/platform users see their own/all
|
||||
// sessions; a delegated ("MSP") admin sees an aggregate across their managed tenants (bounded server-side)
|
||||
// with the tenant filter scoped to that subset — so they get the link too (the /fleet card grid stays
|
||||
// their landing overview).
|
||||
const showDashboard = isTenantMember || hasGlobalScope || isDelegated;
|
||||
const { isRegularUser, showDashboard } = navFlags;
|
||||
|
||||
const renderNavContent = (isMobile = false) => (
|
||||
<>
|
||||
|
||||
@@ -5,6 +5,7 @@ import * as signalR from '@microsoft/signalr';
|
||||
import { api } from '@/lib/api';
|
||||
import { authenticatedFetch } from '@/lib/authenticatedFetch';
|
||||
import { trackEvent } from '@/lib/appInsights';
|
||||
import type { SignalRMessageName } from '@/lib/signalrMessages';
|
||||
import { useAuth } from './AuthContext';
|
||||
|
||||
// Hub payloads are untyped JSON; mirror @microsoft/signalr's own callback signature so
|
||||
@@ -15,8 +16,10 @@ interface SignalRContextType {
|
||||
connection: signalR.HubConnection | null;
|
||||
connectionState: signalR.HubConnectionState;
|
||||
connectionId: string | null;
|
||||
on: (eventName: string, callback: SignalRHandler) => void;
|
||||
off: (eventName: string, callback: SignalRHandler) => void;
|
||||
// Event names are pinned to the backend's message-name catalog (shared manifest) so a
|
||||
// subscription to a name the backend never sends is a compile error, not a silent no-op.
|
||||
on: (eventName: SignalRMessageName, callback: SignalRHandler) => void;
|
||||
off: (eventName: SignalRMessageName, callback: SignalRHandler) => void;
|
||||
invoke: (methodName: string, ...args: unknown[]) => Promise<unknown>;
|
||||
joinGroup: (groupName: string) => Promise<void>;
|
||||
leaveGroup: (groupName: string) => Promise<void>;
|
||||
@@ -186,7 +189,7 @@ export function SignalRProvider({ children }: { children: React.ReactNode }) {
|
||||
// connection. onclose clears the joined-group tracking, and every consumer hook re-joins on
|
||||
// the next Connected transition — through the join endpoint, which re-runs authorization
|
||||
// against the fresh scope. Revoked groups 403; still-authorized streams recover automatically.
|
||||
newConnection.on('accessRevoked', async () => {
|
||||
newConnection.on('accessRevoked' satisfies SignalRMessageName, async () => {
|
||||
// Dropping an event that arrives mid-restart is safe: the rejoin after start() re-runs
|
||||
// authorization against the CURRENT scope, which already reflects that later revoke.
|
||||
if (revokeRestartInFlightRef.current) return;
|
||||
@@ -224,13 +227,13 @@ export function SignalRProvider({ children }: { children: React.ReactNode }) {
|
||||
};
|
||||
}, [isAuthenticated, getAccessToken, syncJoinedGroups]);
|
||||
|
||||
const on = (eventName: string, callback: SignalRHandler) => {
|
||||
const on = (eventName: SignalRMessageName, callback: SignalRHandler) => {
|
||||
if (connectionRef.current) {
|
||||
connectionRef.current.on(eventName, callback);
|
||||
}
|
||||
};
|
||||
|
||||
const off = (eventName: string, callback: SignalRHandler) => {
|
||||
const off = (eventName: SignalRMessageName, callback: SignalRHandler) => {
|
||||
if (connectionRef.current) {
|
||||
connectionRef.current.off(eventName, callback);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
/**
|
||||
* Pins the concrete (override-only) scope projection that used to live duplicated inside
|
||||
* useGlobalAdminScope — the ONLY semantic difference between the former hook twins.
|
||||
* These facts were written against the pre-consolidation hook behavior, so a wrapper
|
||||
* regression (aggregated "" leaking through, delegated home routing global, transient
|
||||
* own-tenant fetch for delegated) turns red here.
|
||||
*/
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { resolveConcreteScopeView } from "../concreteAdminScopeView";
|
||||
|
||||
const OWN = "11111111-1111-1111-1111-111111111111";
|
||||
const OTHER = "22222222-2222-2222-2222-222222222222";
|
||||
|
||||
describe("resolveConcreteScopeView — GA/Reader", () => {
|
||||
it("a persisted aggregated ('') intent resolves to the own tenant, not the aggregate", () => {
|
||||
const v = resolveConcreteScopeView({
|
||||
isGlobalAdmin: true, isDelegatedScope: false,
|
||||
selectedTenantId: "", ownTenantId: OWN, homeTenantId: OWN,
|
||||
});
|
||||
expect(v.selectedTenantId).toBe(OWN);
|
||||
expect(v.effectiveTenantId).toBe(OWN);
|
||||
expect(v.isAggregatedGlobalView).toBe(false);
|
||||
expect(v.isGlobalOverride).toBe(false);
|
||||
expect(v.routeGlobal).toBe(true);
|
||||
});
|
||||
|
||||
it("an override selection targets the picked tenant on the global route", () => {
|
||||
const v = resolveConcreteScopeView({
|
||||
isGlobalAdmin: true, isDelegatedScope: false,
|
||||
selectedTenantId: OTHER, ownTenantId: OWN, homeTenantId: OWN,
|
||||
});
|
||||
expect(v.effectiveTenantId).toBe(OTHER);
|
||||
expect(v.isGlobalOverride).toBe(true);
|
||||
expect(v.routeGlobal).toBe(true);
|
||||
});
|
||||
|
||||
it("before the own tenant resolves, nothing is invented", () => {
|
||||
const v = resolveConcreteScopeView({
|
||||
isGlobalAdmin: true, isDelegatedScope: false,
|
||||
selectedTenantId: "", ownTenantId: "", homeTenantId: undefined,
|
||||
});
|
||||
expect(v.selectedTenantId).toBe("");
|
||||
expect(v.effectiveTenantId).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveConcreteScopeView — delegated (MSP)", () => {
|
||||
it("pre-seed the effective tenant stays empty so pages skip the transient fetch", () => {
|
||||
const v = resolveConcreteScopeView({
|
||||
isGlobalAdmin: true, isDelegatedScope: true,
|
||||
selectedTenantId: "", ownTenantId: OWN, homeTenantId: OWN,
|
||||
});
|
||||
// Deliberately NOT falling back to the own tenant: a delegated caller has no valid
|
||||
// own-tenant data view here.
|
||||
expect(v.selectedTenantId).toBe("");
|
||||
expect(v.effectiveTenantId).toBe("");
|
||||
expect(v.isAggregatedGlobalView).toBe(false);
|
||||
});
|
||||
|
||||
it("a managed tenant routes global", () => {
|
||||
const v = resolveConcreteScopeView({
|
||||
isGlobalAdmin: true, isDelegatedScope: true,
|
||||
selectedTenantId: OTHER, ownTenantId: OWN, homeTenantId: OWN,
|
||||
});
|
||||
expect(v.effectiveTenantId).toBe(OTHER);
|
||||
expect(v.routeGlobal).toBe(true);
|
||||
});
|
||||
|
||||
it("the HOME tenant takes the member path (carve-out)", () => {
|
||||
const v = resolveConcreteScopeView({
|
||||
isGlobalAdmin: true, isDelegatedScope: true,
|
||||
selectedTenantId: OWN, ownTenantId: OWN, homeTenantId: OWN,
|
||||
});
|
||||
expect(v.effectiveTenantId).toBe(OWN);
|
||||
expect(v.routeGlobal).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveConcreteScopeView — regular user", () => {
|
||||
it("resolves to the own tenant on the member path", () => {
|
||||
const v = resolveConcreteScopeView({
|
||||
isGlobalAdmin: false, isDelegatedScope: false,
|
||||
selectedTenantId: "", ownTenantId: OWN, homeTenantId: OWN,
|
||||
});
|
||||
expect(v.effectiveTenantId).toBe(OWN);
|
||||
expect(v.routeGlobal).toBe(false);
|
||||
expect(v.isGlobalOverride).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,46 @@
|
||||
import { isHomeTenantTarget } from "@/utils/homeTenantScope";
|
||||
|
||||
/**
|
||||
* Pure projection of the aggregated admin scope onto the CONCRETE (override-only) page
|
||||
* variant — the single place the two former hook twins actually differed. Extracted so
|
||||
* useGlobalAdminScope can be a thin wrapper over useAggregatedAdminScope and the
|
||||
* per-variant semantics stay pinned by unit tests (concreteAdminScopeView.test.ts).
|
||||
*/
|
||||
export interface ConcreteScopeInputs {
|
||||
/** Cross-tenant mode flag (GA/Reader in GA mode, or delegated always-on). */
|
||||
isGlobalAdmin: boolean;
|
||||
isDelegatedScope: boolean;
|
||||
/** Raw selection from the aggregated hook — "" is a valid aggregated intent there. */
|
||||
selectedTenantId: string;
|
||||
/** The caller's own tenant (useTenant). */
|
||||
ownTenantId: string;
|
||||
/** The caller's home tenant (user.tenantId) — the delegated member-path carve-out target. */
|
||||
homeTenantId: string | undefined;
|
||||
}
|
||||
|
||||
export interface ConcreteScopeView {
|
||||
/** Selection resolved to a concrete tenant: a GA's persisted aggregated ("") intent maps
|
||||
* to their own tenant WITHOUT clearing storage, so aggregated pages still honor it. */
|
||||
selectedTenantId: string;
|
||||
/** Tenant to query; "" for a delegated caller before a managed tenant is seeded (pages
|
||||
* gate their fetch on truthiness to avoid a transient own-tenant request). */
|
||||
effectiveTenantId: string;
|
||||
isGlobalOverride: boolean;
|
||||
/** Never true in this variant once the own tenant is known. */
|
||||
isAggregatedGlobalView: boolean;
|
||||
/** Same carve-out as the aggregated hook: delegated + home tenant → member path. */
|
||||
routeGlobal: boolean;
|
||||
}
|
||||
|
||||
export function resolveConcreteScopeView(i: ConcreteScopeInputs): ConcreteScopeView {
|
||||
const selected = i.isDelegatedScope ? i.selectedTenantId : (i.selectedTenantId || i.ownTenantId);
|
||||
return {
|
||||
selectedTenantId: selected,
|
||||
effectiveTenantId: i.isGlobalAdmin && selected
|
||||
? selected
|
||||
: (i.isDelegatedScope ? "" : i.ownTenantId),
|
||||
isGlobalOverride: Boolean(i.isGlobalAdmin && selected && selected !== i.ownTenantId),
|
||||
isAggregatedGlobalView: Boolean(i.isGlobalAdmin && !selected && !i.isDelegatedScope),
|
||||
routeGlobal: i.isGlobalAdmin && !(i.isDelegatedScope && isHomeTenantTarget(selected, i.homeTenantId)),
|
||||
};
|
||||
}
|
||||
@@ -1,12 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { useTenant } from "@/contexts/TenantContext";
|
||||
import { useAuth } from "@/contexts/AuthContext";
|
||||
import { useAdminMode } from "@/hooks/useAdminMode";
|
||||
import { useTenantList, type TenantInfo } from "@/hooks/useTenantList";
|
||||
import { readTenantScope, writeTenantScope } from "@/utils/tenantScopeStorage";
|
||||
import { delegatedScopedTenantList, isHomeTenantTarget, upnDomain } from "@/utils/homeTenantScope";
|
||||
import { useAggregatedAdminScope } from "@/hooks/useAggregatedAdminScope";
|
||||
import { resolveConcreteScopeView } from "@/hooks/concreteAdminScopeView";
|
||||
import type { TenantInfo } from "@/hooks/useTenantList";
|
||||
|
||||
export type { TenantInfo };
|
||||
|
||||
@@ -47,88 +45,36 @@ export interface GlobalAdminScope {
|
||||
* own tenant for a GA, or the first managed tenant for a delegated ("MSP") admin — and endpoint choice is
|
||||
* keyed on {@link GlobalAdminScope.isGlobalOverride}. There is no aggregated "All tenants" mode here.
|
||||
*
|
||||
* Implemented as a thin projection over {@link useAggregatedAdminScope} (one hook, aggregated as a mode):
|
||||
* all selection/seeding/persistence state lives there; resolveConcreteScopeView maps a GA's persisted
|
||||
* aggregated ("") intent to their own tenant locally — WITHOUT clearing storage, so aggregated pages
|
||||
* still honor it.
|
||||
*
|
||||
* Pair with {@link "@/components/TenantScopeSelector".TenantScopeSelector} for the header dropdown
|
||||
* and {@link "@/components/GlobalAdminBanner".GlobalAdminBanner} for the view bar.
|
||||
*/
|
||||
export function useGlobalAdminScope(): GlobalAdminScope {
|
||||
const { tenantId } = useTenant();
|
||||
const { hasGlobalScope, user } = useAuth();
|
||||
const { globalAdminMode } = useAdminMode();
|
||||
const { user } = useAuth();
|
||||
const agg = useAggregatedAdminScope();
|
||||
|
||||
// Cross-tenant mode: GA/Reader in GA mode, OR a delegated ("MSP") admin (always-on). See AggregatedAdminScope.
|
||||
const isDelegated = user?.isDelegated ?? false;
|
||||
const isDelegatedScope = Boolean(isDelegated && !hasGlobalScope);
|
||||
const isGlobalAdmin = Boolean((globalAdminMode && hasGlobalScope) || isDelegatedScope);
|
||||
|
||||
const allTenants = useTenantList(isGlobalAdmin);
|
||||
// Delegated: managed subset PLUS the caller's own home tenant when they hold a member role there
|
||||
// (member-path access — see utils/homeTenantScope.ts). config/all is backend-bounded to the managed
|
||||
// set, so the home entry is synthesized with the UPN-derived domain when absent.
|
||||
const homeTenantId = user?.tenantId;
|
||||
const hasHomeRole = !!user?.role;
|
||||
const tenants = useMemo(
|
||||
() =>
|
||||
isDelegatedScope
|
||||
? delegatedScopedTenantList(allTenants, user?.delegatedTenantIds, homeTenantId, upnDomain(user?.upn), hasHomeRole)
|
||||
: allTenants,
|
||||
[allTenants, isDelegatedScope, user?.delegatedTenantIds, homeTenantId, user?.upn, hasHomeRole]
|
||||
);
|
||||
|
||||
const [selectedTenantId, setSelectedRaw] = useState<string>("");
|
||||
|
||||
// Persist ONLY on an explicit user action (the selector's onChange). The auto-defaults below use
|
||||
// setSelectedRaw so they never write back — in particular a GA's persisted aggregated ("") intent
|
||||
// is left untouched here (this override-only variant has no aggregate) so aggregated pages still honor it.
|
||||
const setSelectedTenantId = useCallback((id: string) => {
|
||||
setSelectedRaw(id);
|
||||
writeTenantScope(id);
|
||||
}, []);
|
||||
|
||||
// GA/Reader: seed the selection from the tab-persisted choice, else the user's own tenant (never empty
|
||||
// in this variant). A persisted aggregated "" resolves locally to the own tenant without clearing storage.
|
||||
// Render-time (like the delegated seed below), NOT an effect: an effect-time seed exposes one commit
|
||||
// where effectiveTenantId is the caller's own tenant, so pages fire an own-tenant fetch that races the
|
||||
// override fetch — last-resolved wins and the wrong tenant's data can stick until a manual refresh.
|
||||
if (!isDelegatedScope && tenantId && !selectedTenantId) {
|
||||
const stored = readTenantScope();
|
||||
setSelectedRaw(stored ? stored : tenantId);
|
||||
}
|
||||
|
||||
// Delegated: seed from the persisted managed tenant (if still managed) or the first managed tenant once
|
||||
// the scoped list arrives; re-default if the selection falls outside the managed set. Render-time (converges).
|
||||
if (isDelegatedScope && tenants.length > 0 && (!selectedTenantId || !tenants.some((t) => t.tenantId === selectedTenantId))) {
|
||||
const stored = readTenantScope();
|
||||
const storedManaged = stored && tenants.some((t) => t.tenantId === stored) ? stored : null;
|
||||
setSelectedRaw(storedManaged ?? tenants[0].tenantId);
|
||||
}
|
||||
|
||||
// Stale-selection guard (GA): a persisted tenant no longer present in the list falls back to the own
|
||||
// tenant. Local only — never clobbers storage.
|
||||
if (!isDelegatedScope && isGlobalAdmin && selectedTenantId && tenants.length > 0 && !tenants.some((t) => t.tenantId === selectedTenantId)) {
|
||||
setSelectedRaw(tenantId);
|
||||
}
|
||||
|
||||
const isGlobalOverride = Boolean(
|
||||
isGlobalAdmin && selectedTenantId && selectedTenantId !== tenantId
|
||||
);
|
||||
// Delegated has no valid own-tenant data, so before a managed tenant is selected resolve to "" (empty)
|
||||
// — pages gate their fetch on a truthy effectiveTenantId, so this avoids a transient own-tenant request.
|
||||
const effectiveTenantId = isGlobalAdmin && selectedTenantId
|
||||
? selectedTenantId
|
||||
: (isDelegatedScope ? "" : tenantId);
|
||||
const isAggregatedGlobalView = Boolean(isGlobalAdmin && !selectedTenantId && !isDelegatedScope);
|
||||
// Delegated + home tenant → member path (see interface doc). GA/Reader always route global here.
|
||||
const routeGlobal = isGlobalAdmin && !(isDelegatedScope && isHomeTenantTarget(selectedTenantId, homeTenantId));
|
||||
const view = resolveConcreteScopeView({
|
||||
isGlobalAdmin: agg.isGlobalAdmin,
|
||||
isDelegatedScope: agg.isDelegatedScope,
|
||||
selectedTenantId: agg.selectedTenantId,
|
||||
ownTenantId: tenantId,
|
||||
homeTenantId: user?.tenantId,
|
||||
});
|
||||
|
||||
return {
|
||||
isGlobalAdmin,
|
||||
isDelegatedScope,
|
||||
tenants,
|
||||
selectedTenantId,
|
||||
setSelectedTenantId,
|
||||
effectiveTenantId,
|
||||
isGlobalOverride,
|
||||
isAggregatedGlobalView,
|
||||
routeGlobal,
|
||||
isGlobalAdmin: agg.isGlobalAdmin,
|
||||
isDelegatedScope: agg.isDelegatedScope,
|
||||
tenants: agg.tenants,
|
||||
selectedTenantId: view.selectedTenantId,
|
||||
setSelectedTenantId: agg.setSelectedTenantId,
|
||||
effectiveTenantId: view.effectiveTenantId,
|
||||
isGlobalOverride: view.isGlobalOverride,
|
||||
isAggregatedGlobalView: view.isAggregatedGlobalView,
|
||||
routeGlobal: view.routeGlobal,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,318 @@
|
||||
/**
|
||||
* Roles × nav-config × route-guards matrix for the sidebar visibility logic
|
||||
* (lib/navVisibility.ts — extracted verbatim from GlobalSidebar).
|
||||
*
|
||||
* Three layers:
|
||||
* 1. deriveNavFlags — role-state derivations (regular user, dashboard link, member tiers),
|
||||
* 2. group/item/sub-item filtering against the REAL nav config (lib/globalNavConfig),
|
||||
* 3. nav ↔ route-guard consistency: every href the sidebar shows a role must be admitted
|
||||
* by that route's layout guard — "sidebar shows it, guard bounces it" is the drift
|
||||
* class this pins. Known mismatches are an explicit ratchet list: fixing one forces
|
||||
* removing its entry, a new one fails the test.
|
||||
*/
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
DASHBOARD_ITEM,
|
||||
EXPANDABLE_NAV_GROUPS,
|
||||
NAV_GROUPS,
|
||||
REGULAR_USER_ITEMS,
|
||||
} from "../globalNavConfig";
|
||||
import {
|
||||
deriveNavFlags,
|
||||
filterExpandableNavGroups,
|
||||
isNavGroupVisible,
|
||||
type NavFlags,
|
||||
type NavUser,
|
||||
type NavVisibilityInput,
|
||||
} from "../navVisibility";
|
||||
|
||||
// ── role-state fixtures ────────────────────────────────────────────────────────
|
||||
|
||||
function user(overrides: Partial<NavUser> = {}): NavUser {
|
||||
return {
|
||||
isTenantAdmin: false,
|
||||
isGlobalAdmin: false,
|
||||
isDelegated: false,
|
||||
role: null,
|
||||
hasMcpAccess: false,
|
||||
canManageBootstrapTokens: false,
|
||||
bootstrapTokenEnabled: false,
|
||||
unrestrictedModeEnabled: false,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
interface RoleState {
|
||||
name: string;
|
||||
input: NavVisibilityInput;
|
||||
}
|
||||
|
||||
const ROLE_STATES: RoleState[] = [
|
||||
{
|
||||
name: "GlobalAdmin+adminModeOn",
|
||||
input: { user: user({ isGlobalAdmin: true }), hasGlobalScope: true, hasFleetScope: true, globalAdminMode: true },
|
||||
},
|
||||
{
|
||||
name: "GlobalAdmin+adminModeOff",
|
||||
input: { user: user({ isGlobalAdmin: true }), hasGlobalScope: true, hasFleetScope: true, globalAdminMode: false },
|
||||
},
|
||||
{
|
||||
name: "GlobalReader+adminModeOn",
|
||||
input: { user: user(), hasGlobalScope: true, hasFleetScope: true, globalAdminMode: true },
|
||||
},
|
||||
{
|
||||
name: "DelegatedPure",
|
||||
input: { user: user({ isDelegated: true }), hasGlobalScope: false, hasFleetScope: true, globalAdminMode: false },
|
||||
},
|
||||
{
|
||||
name: "Delegated+OwnAdminRole",
|
||||
input: { user: user({ isDelegated: true, isTenantAdmin: true, role: "Admin" }), hasGlobalScope: false, hasFleetScope: true, globalAdminMode: false },
|
||||
},
|
||||
{
|
||||
name: "Delegated+GlobalAdmin",
|
||||
input: { user: user({ isDelegated: true, isGlobalAdmin: true }), hasGlobalScope: true, hasFleetScope: true, globalAdminMode: true },
|
||||
},
|
||||
{
|
||||
name: "TenantAdmin",
|
||||
input: { user: user({ isTenantAdmin: true, role: "Admin" }), hasGlobalScope: false, hasFleetScope: false, globalAdminMode: false },
|
||||
},
|
||||
{
|
||||
name: "Operator",
|
||||
input: { user: user({ role: "Operator" }), hasGlobalScope: false, hasFleetScope: false, globalAdminMode: false },
|
||||
},
|
||||
{
|
||||
name: "Viewer",
|
||||
input: { user: user({ role: "Viewer" }), hasGlobalScope: false, hasFleetScope: false, globalAdminMode: false },
|
||||
},
|
||||
{
|
||||
name: "RegularMember",
|
||||
input: { user: user(), hasGlobalScope: false, hasFleetScope: false, globalAdminMode: false },
|
||||
},
|
||||
{
|
||||
name: "NoUser(authLoading)",
|
||||
input: { user: null, hasGlobalScope: false, hasFleetScope: false, globalAdminMode: false },
|
||||
},
|
||||
];
|
||||
|
||||
const flagsOf = (name: string): NavFlags =>
|
||||
deriveNavFlags(ROLE_STATES.find((s) => s.name === name)!.input);
|
||||
|
||||
const visibleGroupIds = (flags: NavFlags): string[] =>
|
||||
[...NAV_GROUPS, ...EXPANDABLE_NAV_GROUPS]
|
||||
.filter((g) => isNavGroupVisible(g, flags))
|
||||
.map((g) => g.id);
|
||||
|
||||
// ── layer 1: flag derivations ─────────────────────────────────────────────────
|
||||
|
||||
describe("deriveNavFlags", () => {
|
||||
it.each([
|
||||
["GlobalAdmin+adminModeOn", false, true],
|
||||
["GlobalReader+adminModeOn", false, true],
|
||||
["DelegatedPure", false, true],
|
||||
["TenantAdmin", false, true],
|
||||
["Operator", false, true],
|
||||
["Viewer", false, true],
|
||||
["RegularMember", true, false],
|
||||
["NoUser(authLoading)", true, false],
|
||||
] as const)("%s → isRegularUser=%s, showDashboard=%s", (name, regular, dashboard) => {
|
||||
const flags = flagsOf(name);
|
||||
expect(flags.isRegularUser).toBe(regular);
|
||||
expect(flags.showDashboard).toBe(dashboard);
|
||||
});
|
||||
|
||||
it("all three tenant roles count as tenant members", () => {
|
||||
for (const name of ["TenantAdmin", "Operator", "Viewer"]) {
|
||||
expect(flagsOf(name).isTenantMember).toBe(true);
|
||||
}
|
||||
expect(flagsOf("RegularMember").isTenantMember).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ── layer 2: group visibility against the real nav config ─────────────────────
|
||||
|
||||
describe("group visibility matrix", () => {
|
||||
it("tenant members and platform scopes see the standard groups; regular users see none", () => {
|
||||
for (const name of ["TenantAdmin", "Operator", "Viewer", "GlobalReader+adminModeOn", "GlobalAdmin+adminModeOff"]) {
|
||||
const ids = visibleGroupIds(flagsOf(name));
|
||||
for (const groupId of ["monitoring", "rules", "operations", "configuration"]) {
|
||||
expect(ids, `${name} must see '${groupId}'`).toContain(groupId);
|
||||
}
|
||||
}
|
||||
expect(visibleGroupIds(flagsOf("RegularMember"))).toEqual([]);
|
||||
expect(visibleGroupIds(flagsOf("NoUser(authLoading)"))).toEqual([]);
|
||||
});
|
||||
|
||||
it("the Global Admin group needs platform scope AND the admin-mode toggle", () => {
|
||||
expect(visibleGroupIds(flagsOf("GlobalAdmin+adminModeOn"))).toContain("global-admin");
|
||||
expect(visibleGroupIds(flagsOf("GlobalReader+adminModeOn"))).toContain("global-admin");
|
||||
expect(visibleGroupIds(flagsOf("GlobalAdmin+adminModeOff"))).not.toContain("global-admin");
|
||||
expect(visibleGroupIds(flagsOf("TenantAdmin"))).not.toContain("global-admin");
|
||||
});
|
||||
|
||||
it("the fleet group shows for delegated admins without platform scope only", () => {
|
||||
expect(visibleGroupIds(flagsOf("DelegatedPure"))).toContain("fleet");
|
||||
expect(visibleGroupIds(flagsOf("Delegated+OwnAdminRole"))).toContain("fleet");
|
||||
expect(visibleGroupIds(flagsOf("Delegated+GlobalAdmin"))).not.toContain("fleet");
|
||||
expect(visibleGroupIds(flagsOf("TenantAdmin"))).not.toContain("fleet");
|
||||
});
|
||||
|
||||
it("a delegated admin with an own-tenant role sees fleet AND tenant groups", () => {
|
||||
const ids = visibleGroupIds(flagsOf("Delegated+OwnAdminRole"));
|
||||
expect(ids).toContain("fleet");
|
||||
expect(ids).toContain("monitoring");
|
||||
expect(ids).toContain("configuration");
|
||||
// Pure delegated: no tenant groups.
|
||||
expect(visibleGroupIds(flagsOf("DelegatedPure"))).not.toContain("configuration");
|
||||
});
|
||||
});
|
||||
|
||||
describe("item and sub-item filtering", () => {
|
||||
const itemIds = (flags: NavFlags): string[] =>
|
||||
filterExpandableNavGroups(EXPANDABLE_NAV_GROUPS, flags).flatMap((g) => g.items.map((i) => i.id));
|
||||
const subIds = (flags: NavFlags): string[] =>
|
||||
filterExpandableNavGroups(EXPANDABLE_NAV_GROUPS, flags).flatMap((g) =>
|
||||
g.items.flatMap((i) => i.items.map((s) => s.id)));
|
||||
|
||||
it("globalAdminOnly items show for the real GA and never for the read-only reader", () => {
|
||||
const gaItems = itemIds(flagsOf("GlobalAdmin+adminModeOn"));
|
||||
const readerItems = itemIds(flagsOf("GlobalReader+adminModeOn"));
|
||||
for (const id of ["ga-settings", "ga-ops", "ga-software"]) {
|
||||
expect(gaItems).toContain(id);
|
||||
expect(readerItems, `reader must not see '${id}'`).not.toContain(id);
|
||||
}
|
||||
// The reader keeps the non-mutating GA items.
|
||||
for (const id of ["ga-tenants", "ga-metrics", "ga-reports", "ga-security"]) {
|
||||
expect(readerItems).toContain(id);
|
||||
}
|
||||
});
|
||||
|
||||
it("cfg-reporting requires per-user MCP access", () => {
|
||||
expect(itemIds(flagsOf("TenantAdmin"))).not.toContain("cfg-reporting");
|
||||
const withMcp = deriveNavFlags({
|
||||
user: user({ isTenantAdmin: true, role: "Admin", hasMcpAccess: true }),
|
||||
hasGlobalScope: false, hasFleetScope: false, globalAdminMode: false,
|
||||
});
|
||||
expect(itemIds(withMcp)).toContain("cfg-reporting");
|
||||
});
|
||||
|
||||
it("tenant-admin-only sub-sections hide from Operators and Viewers but not platform scopes", () => {
|
||||
for (const sub of ["cfg-autopilot", "cfg-access-mgmt", "cfg-offboarding"]) {
|
||||
expect(subIds(flagsOf("TenantAdmin"))).toContain(sub);
|
||||
expect(subIds(flagsOf("GlobalReader+adminModeOn"))).toContain(sub);
|
||||
expect(subIds(flagsOf("Operator")), `Operator must not see '${sub}'`).not.toContain(sub);
|
||||
expect(subIds(flagsOf("Viewer")), `Viewer must not see '${sub}'`).not.toContain(sub);
|
||||
}
|
||||
});
|
||||
|
||||
it("feature-flagged sub-items follow their tenant flags and role gates", () => {
|
||||
// Off by default:
|
||||
expect(subIds(flagsOf("TenantAdmin"))).not.toContain("cfg-bootstrap-sessions");
|
||||
expect(subIds(flagsOf("TenantAdmin"))).not.toContain("cfg-agent-unrestricted");
|
||||
// Flag on + admin:
|
||||
const flaggedAdmin = deriveNavFlags({
|
||||
user: user({ isTenantAdmin: true, role: "Admin", bootstrapTokenEnabled: true, unrestrictedModeEnabled: true }),
|
||||
hasGlobalScope: false, hasFleetScope: false, globalAdminMode: false,
|
||||
});
|
||||
expect(subIds(flaggedAdmin)).toContain("cfg-bootstrap-sessions");
|
||||
expect(subIds(flaggedAdmin)).toContain("cfg-agent-unrestricted");
|
||||
// Bootstrap manager permission substitutes for admin on bootstrap sessions only:
|
||||
const bootstrapOperator = deriveNavFlags({
|
||||
user: user({ role: "Operator", canManageBootstrapTokens: true, bootstrapTokenEnabled: true, unrestrictedModeEnabled: true }),
|
||||
hasGlobalScope: false, hasFleetScope: false, globalAdminMode: false,
|
||||
});
|
||||
expect(subIds(bootstrapOperator)).toContain("cfg-bootstrap-sessions");
|
||||
expect(subIds(bootstrapOperator)).not.toContain("cfg-agent-unrestricted");
|
||||
});
|
||||
});
|
||||
|
||||
// ── layer 3: nav ↔ route-guard consistency ────────────────────────────────────
|
||||
|
||||
describe("nav ↔ route-guard consistency", () => {
|
||||
/**
|
||||
* Independent oracle of the layout guards (NOT derived from the components):
|
||||
* - app/admin/layout.tsx → ProtectedRoute requireGlobalScope; the deeper GA-only
|
||||
* layouts (settings/ops/backups/customs-archive/software) → requireGlobalAdmin,
|
||||
* - app/sessions/inspector/layout.tsx → requireGlobalAdmin,
|
||||
* - app/fleet/layout.tsx → requireFleetScope,
|
||||
* - app/settings/layout.tsx → rule A (admin/GA/Operator/Viewer) minus rule B
|
||||
* (Operator without admin standing needs canManageBootstrapTokens).
|
||||
* Longest prefix wins. Routes without a declared guard have no layout gate.
|
||||
*/
|
||||
const ROUTE_GUARDS: Array<{ prefix: string; admits: (input: NavVisibilityInput, flags: NavFlags) => boolean }> = [
|
||||
{ prefix: "/admin/settings", admits: (_i, f) => f.isGlobalAdmin },
|
||||
{ prefix: "/admin/ops", admits: (_i, f) => f.isGlobalAdmin },
|
||||
{ prefix: "/admin/backups", admits: (_i, f) => f.isGlobalAdmin },
|
||||
{ prefix: "/admin/customs-archive", admits: (_i, f) => f.isGlobalAdmin },
|
||||
{ prefix: "/admin/software", admits: (_i, f) => f.isGlobalAdmin },
|
||||
{ prefix: "/sessions/inspector", admits: (_i, f) => f.isGlobalAdmin },
|
||||
{ prefix: "/admin", admits: (_i, f) => f.hasGlobalScope },
|
||||
{ prefix: "/fleet", admits: (_i, f) => f.hasFleetScope },
|
||||
{
|
||||
prefix: "/settings",
|
||||
admits: (i, f) => {
|
||||
const role = i.user?.role ?? null;
|
||||
const admitted = f.isAdminLike || role === "Operator" || role === "Viewer";
|
||||
const operatorBounced = role === "Operator" && !f.isAdminLike && !(i.user?.canManageBootstrapTokens ?? false);
|
||||
return admitted && !operatorBounced;
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* Ratchet of KNOWN sidebar-shows-it/guard-bounces-it mismatches (pre-existing, user
|
||||
* decision pending — see tasks/todo.md P7.4): a plain Operator and a read-only
|
||||
* GlobalReader both see Configuration entries whose /settings layout bounces them.
|
||||
* Fixing a guard requires deleting its entry here; any NEW mismatch fails the test.
|
||||
*/
|
||||
const KNOWN_MISMATCHES = new Set([
|
||||
"Operator × /settings",
|
||||
"GlobalReader+adminModeOn × /settings",
|
||||
]);
|
||||
|
||||
const guardFor = (href: string) =>
|
||||
ROUTE_GUARDS.filter((g) => href === g.prefix || href.startsWith(g.prefix + "/"))
|
||||
.sort((a, b) => b.prefix.length - a.prefix.length)[0];
|
||||
|
||||
const visibleHrefs = (input: NavVisibilityInput): string[] => {
|
||||
const flags = deriveNavFlags(input);
|
||||
const hrefs: string[] = [];
|
||||
if (flags.showDashboard) hrefs.push(DASHBOARD_ITEM.href);
|
||||
if (flags.isRegularUser) hrefs.push(...REGULAR_USER_ITEMS.map((i) => i.href));
|
||||
for (const group of NAV_GROUPS.filter((g) => isNavGroupVisible(g, flags))) {
|
||||
hrefs.push(...group.items.map((i) => i.href));
|
||||
}
|
||||
for (const group of filterExpandableNavGroups(EXPANDABLE_NAV_GROUPS, flags)) {
|
||||
hrefs.push(...group.items.flatMap((i) => i.items.map((s) => s.href)));
|
||||
}
|
||||
return hrefs;
|
||||
};
|
||||
|
||||
it("every visible href is admitted by its route guard (known mismatches ratcheted)", () => {
|
||||
const mismatches = new Set<string>();
|
||||
for (const state of ROLE_STATES) {
|
||||
const flags = deriveNavFlags(state.input);
|
||||
for (const href of visibleHrefs(state.input)) {
|
||||
const guard = guardFor(href);
|
||||
if (!guard) continue;
|
||||
if (!guard.admits(state.input, flags)) {
|
||||
mismatches.add(`${state.name} × ${guard.prefix}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
expect([...mismatches].sort()).toEqual([...KNOWN_MISMATCHES].sort());
|
||||
});
|
||||
|
||||
it("guard oracle covers every /admin, /fleet and /settings href in the nav config", () => {
|
||||
const allHrefs = [
|
||||
DASHBOARD_ITEM.href,
|
||||
...REGULAR_USER_ITEMS.map((i) => i.href),
|
||||
...NAV_GROUPS.flatMap((g) => g.items.map((i) => i.href)),
|
||||
...EXPANDABLE_NAV_GROUPS.flatMap((g) => g.items.flatMap((i) => i.items.map((s) => s.href))),
|
||||
];
|
||||
for (const href of allHrefs) {
|
||||
if (/^\/(admin|fleet|settings)(\/|$)/.test(href) || href.startsWith("/sessions/inspector")) {
|
||||
expect(guardFor(href), `no guard oracle entry covers '${href}'`).toBeTruthy();
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,74 @@
|
||||
/**
|
||||
* Routing matrix for the scope-aware URL builders: tenant path / global override /
|
||||
* global aggregated / delegated-home member path — the four states every one of the
|
||||
* former hand-rolled call sites had to get right, incl. tenant-parameter placement
|
||||
* (the audit's positional-argument trap) and the '' → no-param aggregate idiom.
|
||||
*/
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("@/utils/config", () => ({ API_BASE_URL: "https://test.example" }));
|
||||
|
||||
import { scopedApi, type TenantScopeSelection } from "../scopedApi";
|
||||
|
||||
const OWN = "11111111-1111-1111-1111-111111111111";
|
||||
const OTHER = "22222222-2222-2222-2222-222222222222";
|
||||
|
||||
const tenantMode: TenantScopeSelection = { routeGlobal: false, selectedTenantId: OWN, effectiveTenantId: OWN };
|
||||
const override: TenantScopeSelection = { routeGlobal: true, selectedTenantId: OTHER, effectiveTenantId: OTHER };
|
||||
const aggregated: TenantScopeSelection = { routeGlobal: true, selectedTenantId: "", effectiveTenantId: "" };
|
||||
|
||||
describe("scopedApi routing", () => {
|
||||
it("tenant mode targets the member-path variant with the own tenant", () => {
|
||||
const url = scopedApi.appMetrics(tenantMode, 30);
|
||||
expect(url).toContain("/api/metrics/app");
|
||||
expect(url).toContain(`tenantId=${OWN}`);
|
||||
expect(url).not.toContain("/global/");
|
||||
expect(scopedApi.fleetHealth(tenantMode, 30)).not.toContain("/global/");
|
||||
expect(scopedApi.geographic(tenantMode, 30, "country")).toContain(`tenantId=${OWN}`);
|
||||
});
|
||||
|
||||
it("override targets the global variant with the selected tenant as query param", () => {
|
||||
const url = scopedApi.appMetrics(override, 30);
|
||||
expect(url).toContain("/global/");
|
||||
expect(url).toContain(`tenantId=${OTHER}`);
|
||||
expect(scopedApi.auditLogs(override, {})).toContain(`tenantId=${OTHER}`);
|
||||
expect(scopedApi.annotationsList(override, {})).toContain(`tenantId=${OTHER}`);
|
||||
});
|
||||
|
||||
it("aggregated ('') sends the global variant WITHOUT a tenantId param", () => {
|
||||
for (const url of [
|
||||
scopedApi.appMetrics(aggregated, 30),
|
||||
scopedApi.fleetHealth(aggregated, 30),
|
||||
scopedApi.vulnerability(aggregated, 30, 10),
|
||||
scopedApi.auditLogs(aggregated, {}),
|
||||
scopedApi.appsList(aggregated, 30),
|
||||
]) {
|
||||
expect(url).toContain("/global/");
|
||||
expect(url).not.toContain("tenantId=");
|
||||
}
|
||||
});
|
||||
|
||||
it("the delegated-home member path is whatever routeGlobal says — no local re-derivation", () => {
|
||||
// The carve-out lives in the scope hooks; the builder must follow routeGlobal blindly.
|
||||
const delegatedHome: TenantScopeSelection = { routeGlobal: false, selectedTenantId: OWN, effectiveTenantId: OWN };
|
||||
expect(scopedApi.appAnalytics(delegatedHome, "App", 30)).not.toContain("/global/");
|
||||
});
|
||||
|
||||
it("a corrupted non-GUID selection is dropped from the global query, not sent", () => {
|
||||
const corrupt: TenantScopeSelection = { routeGlobal: true, selectedTenantId: "not-a-guid", effectiveTenantId: "not-a-guid" };
|
||||
expect(scopedApi.appMetrics(corrupt, 30)).not.toContain("tenantId=");
|
||||
});
|
||||
|
||||
it("appSessions places every positional argument on both variants", () => {
|
||||
const t = scopedApi.appSessions(tenantMode, "My App", 7, "failed", 20, 10);
|
||||
const g = scopedApi.appSessions(override, "My App", 7, "failed", 20, 10);
|
||||
for (const url of [t, g]) {
|
||||
expect(url).toContain("My%20App");
|
||||
expect(url).toContain("days=7");
|
||||
expect(url).toContain("status=failed");
|
||||
expect(url).toContain("offset=20");
|
||||
expect(url).toContain("limit=10");
|
||||
}
|
||||
expect(g).toContain(`tenantId=${OTHER}`);
|
||||
});
|
||||
});
|
||||
@@ -132,6 +132,13 @@ export const api = {
|
||||
config: {
|
||||
all: () => `${API_BASE_URL}/api/config/all`,
|
||||
tenant: (tenantId: string) => `${API_BASE_URL}/api/config/${tenantId}`,
|
||||
/**
|
||||
* PATCH — transactional field-level config write (TenantAdminOrGA, own row):
|
||||
* { fields: { <wireFieldName>: value | null, ... }, reason?: string }. The backend
|
||||
* verifies exactly those fields changed and rolls back on drift; every write snapshots
|
||||
* to ConfigurationBackups first. The Settings sections save through this per section.
|
||||
*/
|
||||
fields: (tenantId: string) => `${API_BASE_URL}/api/config/${tenantId}/fields`,
|
||||
featureFlags: (tenantId: string) => `${API_BASE_URL}/api/config/${tenantId}/feature-flags`,
|
||||
/** PATCH — GA-only plan/trial mutation: { planTier?, trialExpiresUtc?: ISO | null }. */
|
||||
plan: (tenantId: string) => `${API_BASE_URL}/api/config/${tenantId}/plan`,
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
import type { ExpandableNavGroup, NavGroup } from "./globalNavConfig";
|
||||
|
||||
/**
|
||||
* Pure sidebar-visibility logic, extracted from GlobalSidebar so the full
|
||||
* roles × nav-config matrix is testable without a DOM (house pattern — see
|
||||
* aggregatedAdminScopeSeed / decideHostBounce). GlobalSidebar consumes these
|
||||
* verbatim; behavior changes belong HERE so the matrix test sees them.
|
||||
*/
|
||||
|
||||
/** Structural subset of AuthContext's UserInfo the nav logic reads. */
|
||||
export interface NavUser {
|
||||
isTenantAdmin?: boolean;
|
||||
isGlobalAdmin?: boolean;
|
||||
isDelegated?: boolean;
|
||||
role?: string | null;
|
||||
hasMcpAccess?: boolean;
|
||||
canManageBootstrapTokens?: boolean;
|
||||
bootstrapTokenEnabled?: boolean;
|
||||
unrestrictedModeEnabled?: boolean;
|
||||
}
|
||||
|
||||
export interface NavVisibilityInput {
|
||||
user: NavUser | null | undefined;
|
||||
/** AuthContext derivation: isGlobalAdmin || isGlobalReader. */
|
||||
hasGlobalScope: boolean;
|
||||
/** AuthContext derivation: isGlobalAdmin || isGlobalReader || isDelegated. */
|
||||
hasFleetScope: boolean;
|
||||
/** The Global View toggle (localStorage) — gates the purple Global Admin section. */
|
||||
globalAdminMode: boolean;
|
||||
}
|
||||
|
||||
export interface NavFlags {
|
||||
isTenantMember: boolean;
|
||||
isGlobalAdmin: boolean;
|
||||
isDelegated: boolean;
|
||||
hasGlobalScope: boolean;
|
||||
hasFleetScope: boolean;
|
||||
globalAdminMode: boolean;
|
||||
hasMcpAccess: boolean;
|
||||
isAdminLike: boolean;
|
||||
canManageBootstrapTokens: boolean;
|
||||
bootstrapTokenEnabled: boolean;
|
||||
unrestrictedModeEnabled: boolean;
|
||||
/** Minimal nav (Progress Portal only): no tenant role and no fleet/platform scope. */
|
||||
isRegularUser: boolean;
|
||||
/** The cross-tenant session browser link. */
|
||||
showDashboard: boolean;
|
||||
}
|
||||
|
||||
export function deriveNavFlags({ user, hasGlobalScope, hasFleetScope, globalAdminMode }: NavVisibilityInput): NavFlags {
|
||||
const isTenantAdmin = user?.isTenantAdmin ?? false;
|
||||
const isOperator = user?.role === "Operator";
|
||||
const isViewer = user?.role === "Viewer";
|
||||
// Any resolved tenant role — Admin, Operator, or read-only Viewer. The Viewer sees the same
|
||||
// monitoring/rules/operations/configuration nav; read-only is enforced inside the pages.
|
||||
const isTenantMember = isTenantAdmin || isOperator || isViewer;
|
||||
const isGlobalAdmin = user?.isGlobalAdmin ?? false;
|
||||
const isDelegated = user?.isDelegated ?? false;
|
||||
|
||||
return {
|
||||
isTenantMember,
|
||||
isGlobalAdmin,
|
||||
isDelegated,
|
||||
hasGlobalScope,
|
||||
hasFleetScope,
|
||||
globalAdminMode,
|
||||
hasMcpAccess: user?.hasMcpAccess ?? false,
|
||||
isAdminLike: isTenantAdmin || isGlobalAdmin,
|
||||
canManageBootstrapTokens: user?.canManageBootstrapTokens ?? false,
|
||||
bootstrapTokenEnabled: user?.bootstrapTokenEnabled ?? false,
|
||||
unrestrictedModeEnabled: user?.unrestrictedModeEnabled ?? false,
|
||||
// Regular users see minimal nav. A read-only Global Reader has platform scope, and a
|
||||
// delegated MSP admin has fleet scope → both get the group-filtered nav instead.
|
||||
isRegularUser: !isTenantMember && !hasFleetScope,
|
||||
// Own-tenant/platform users see their own/all sessions; a delegated ("MSP") admin sees a
|
||||
// bounded aggregate across their managed tenants — so they get the link too.
|
||||
showDashboard: isTenantMember || hasGlobalScope || isDelegated,
|
||||
};
|
||||
}
|
||||
|
||||
export function isNavGroupVisible(group: Pick<NavGroup | ExpandableNavGroup, "visibility">, flags: NavFlags): boolean {
|
||||
switch (group.visibility) {
|
||||
case "all": return true;
|
||||
// A GlobalReader is "GA minus writes" — it must see the SAME sidebar as a real GA. The standard
|
||||
// monitoring/rules/operations/configuration groups are normally tenant-member scope; open them to any
|
||||
// platform scope so a pure GlobalReader (no own-tenant role) gets Fleet Health, Usage Metrics, SLA,
|
||||
// etc. Read-only is enforced inside the pages (useCanMutatePlatform + backend), not by hiding nav.
|
||||
case "tenantMember": return flags.isTenantMember || flags.hasGlobalScope;
|
||||
// The (purple) Global Admin section is gated on the Global View toggle — IDENTICAL to a real GA:
|
||||
// toggle off → hidden, toggle on → shown. Item-level globalAdminOnly entries (Settings/Ops/Software)
|
||||
// still drop out for a read-only reader via the item filter below.
|
||||
case "globalAdmin": return flags.hasGlobalScope && flags.globalAdminMode;
|
||||
// Fleet (MSP) nav: shown to a delegated admin who does NOT have full platform scope. A GA/Reader
|
||||
// gets the standard monitoring groups above instead, so it stays hidden for them (no duplication).
|
||||
case "fleet": return flags.isDelegated && !flags.hasGlobalScope;
|
||||
default: return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Group → item → sub-item filtering for the expandable groups (Configuration, Global Admin):
|
||||
* feature gates (MCP reporting, bootstrap sessions, unrestricted mode) and role gates
|
||||
* (globalAdminOnly items, tenant-admin-only sub-sections). Items whose sub-items all filter
|
||||
* away are dropped entirely.
|
||||
*/
|
||||
export function filterExpandableNavGroups(
|
||||
groups: readonly ExpandableNavGroup[],
|
||||
flags: NavFlags,
|
||||
): ExpandableNavGroup[] {
|
||||
return groups
|
||||
.filter((group) => isNavGroupVisible(group, flags))
|
||||
.map((group) => {
|
||||
const filteredItems = group.items
|
||||
.filter((item) => {
|
||||
// MCP reporting requires per-user MCP access.
|
||||
if (item.id === "cfg-reporting") return flags.hasMcpAccess;
|
||||
// Platform-settings/mutation sub-sections are real-GA-only: hide from a read-only
|
||||
// Global Reader (who reaches the group via hasGlobalScope visibility).
|
||||
if ("visibility" in item && item.visibility === "globalAdminOnly") return flags.isGlobalAdmin;
|
||||
return true;
|
||||
})
|
||||
.map((item) => {
|
||||
// Sub-item gating for feature-flagged entries (tenant feature flags)
|
||||
const filteredSubs = item.items.filter((sub) => {
|
||||
if (sub.id === "cfg-bootstrap-sessions") {
|
||||
return flags.bootstrapTokenEnabled && (flags.isAdminLike || flags.canManageBootstrapTokens);
|
||||
}
|
||||
if (sub.id === "cfg-agent-unrestricted") {
|
||||
return flags.isAdminLike && flags.unrestrictedModeEnabled;
|
||||
}
|
||||
// Tenant-admin-only sub-sections: Operators and Viewers (read-only settings
|
||||
// viewers) don't see them — matches the in-page "tenant administrators only" gates. A platform
|
||||
// scope (GA / read-only GlobalReader) keeps the full GA-identical sidebar.
|
||||
if (sub.id === "cfg-autopilot" || sub.id === "cfg-access-mgmt" || sub.id === "cfg-offboarding") {
|
||||
return flags.isAdminLike || flags.hasGlobalScope;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
return { ...item, items: filteredSubs };
|
||||
})
|
||||
// Drop items whose sub-items have all been filtered out
|
||||
.filter((item) => item.items.length > 0);
|
||||
return { ...group, items: filteredItems };
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import { api } from "./api";
|
||||
import { asGuidOrUndefined } from "@/utils/inputValidation";
|
||||
|
||||
/**
|
||||
* Scope-aware URL builders for the tenant/global endpoint pairs in lib/api.ts.
|
||||
*
|
||||
* Every cross-tenant page used to hand-roll the same idiom at each call site:
|
||||
* routeGlobal ? api.x.globalY(..., selectedTenantId || undefined) : api.x.y(tenantId, ...)
|
||||
* — with the tenantId parameter sitting at a DIFFERENT position in nearly every global
|
||||
* variant (the exact trap the 2026-08 audit flagged). These builders take the scope
|
||||
* selection ONCE and own the routing decision + tenant-parameter placement, so a page
|
||||
* can no longer pick the wrong variant, misplace the tenant argument, or bypass the
|
||||
* delegated-home carve-out that the scope hooks encode in `routeGlobal`.
|
||||
*/
|
||||
export interface TenantScopeSelection {
|
||||
/** From the scope hook: global route incl. the delegated-home carve-out. */
|
||||
routeGlobal: boolean;
|
||||
/** From the scope hook: "" = aggregated (GA only), else a tenant id. */
|
||||
selectedTenantId: string;
|
||||
/**
|
||||
* From the scope hook. Whenever routeGlobal is false this IS the caller's own tenant
|
||||
* (non-GA user, or delegated on their home tenant) — the member-path variants use it.
|
||||
* Both scope hooks return these three fields, so pages pass their scope object verbatim.
|
||||
*/
|
||||
effectiveTenantId: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tenant query param for the /global/ variants: "" (aggregated) → undefined, and anything
|
||||
* that is not a GUID is dropped too (defense in depth — selections come from the tenant
|
||||
* list, so a non-GUID value is corrupted state, not intent; mirrors useFleetHealth).
|
||||
*/
|
||||
const globalTenantParam = (sel: TenantScopeSelection): string | undefined =>
|
||||
asGuidOrUndefined(sel.selectedTenantId.trim());
|
||||
|
||||
export const scopedApi = {
|
||||
fleetHealth: (sel: TenantScopeSelection, days: number) =>
|
||||
sel.routeGlobal ? api.metrics.globalFleetHealth(days, globalTenantParam(sel)) : api.metrics.fleetHealth(days),
|
||||
|
||||
appMetrics: (sel: TenantScopeSelection, days: number) =>
|
||||
sel.routeGlobal ? api.metrics.globalApp(days, globalTenantParam(sel)) : api.metrics.app(sel.effectiveTenantId, days),
|
||||
|
||||
timeAttribution: (sel: TenantScopeSelection) =>
|
||||
sel.routeGlobal ? api.metrics.globalTimeAttribution(globalTenantParam(sel)) : api.metrics.timeAttribution(),
|
||||
|
||||
deviceJourneys: (sel: TenantScopeSelection, days: number) =>
|
||||
sel.routeGlobal ? api.metrics.globalDeviceJourneys(days, globalTenantParam(sel)) : api.metrics.deviceJourneys(days),
|
||||
|
||||
geographic: (sel: TenantScopeSelection, days: number, groupBy: string) =>
|
||||
sel.routeGlobal
|
||||
? api.metrics.globalGeographic(days, groupBy, globalTenantParam(sel))
|
||||
: api.metrics.geographic(sel.effectiveTenantId, days, groupBy),
|
||||
|
||||
vulnerability: (sel: TenantScopeSelection, days: number, topN: number) =>
|
||||
sel.routeGlobal
|
||||
? api.metrics.globalVulnerability(days, topN, globalTenantParam(sel))
|
||||
: api.metrics.vulnerability(days, topN),
|
||||
|
||||
appsList: (sel: TenantScopeSelection, days: number) =>
|
||||
sel.routeGlobal ? api.apps.globalList(days, globalTenantParam(sel)) : api.apps.list(sel.effectiveTenantId, days),
|
||||
|
||||
appAnalytics: (sel: TenantScopeSelection, appName: string, days: number) =>
|
||||
sel.routeGlobal
|
||||
? api.apps.globalAnalytics(appName, days, globalTenantParam(sel))
|
||||
: api.apps.analytics(sel.effectiveTenantId, appName, days),
|
||||
|
||||
appSessions: (
|
||||
sel: TenantScopeSelection,
|
||||
appName: string,
|
||||
days: number,
|
||||
status: "all" | "failed" | "succeeded",
|
||||
offset: number,
|
||||
limit: number,
|
||||
opts?: { model?: string; version?: string },
|
||||
) =>
|
||||
sel.routeGlobal
|
||||
? api.apps.globalSessions(appName, days, status, offset, limit, globalTenantParam(sel), opts)
|
||||
: api.apps.sessions(sel.effectiveTenantId, appName, days, status, offset, limit, opts),
|
||||
|
||||
annotationsList: (
|
||||
sel: TenantScopeSelection,
|
||||
opts: { verdict?: string; lane?: string; pageSize?: number; continuation?: string },
|
||||
) =>
|
||||
sel.routeGlobal
|
||||
? api.annotations.globalList({ ...opts, tenantId: globalTenantParam(sel) })
|
||||
: api.annotations.list(opts),
|
||||
|
||||
auditLogs: (
|
||||
sel: TenantScopeSelection,
|
||||
opts: { dateFrom?: string; dateTo?: string; pageSize?: number; continuation?: string; excludeDeletions?: boolean },
|
||||
) =>
|
||||
sel.routeGlobal
|
||||
? api.audit.globalLogs({ ...opts, tenantId: globalTenantParam(sel) })
|
||||
: api.audit.logs(opts),
|
||||
};
|
||||
@@ -0,0 +1,36 @@
|
||||
import { authenticatedFetch } from "./authenticatedFetch";
|
||||
|
||||
/**
|
||||
* JSON fetch layer for the scope-migrated pages: authenticatedFetch + ok-check + body
|
||||
* parse in one call, so call sites stop hand-rolling the response boilerplate. Throws
|
||||
* ApiError on a non-ok response with the backend's `message` when the body carries one
|
||||
* (TokenExpiredError from authenticatedFetch passes through untouched).
|
||||
*/
|
||||
export class ApiError extends Error {
|
||||
constructor(
|
||||
public readonly status: number,
|
||||
message: string,
|
||||
) {
|
||||
super(message);
|
||||
this.name = "ApiError";
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchJson<T>(
|
||||
url: string,
|
||||
getAccessToken: (forceRefresh?: boolean) => Promise<string | null>,
|
||||
init?: RequestInit,
|
||||
): Promise<T> {
|
||||
const response = await authenticatedFetch(url, getAccessToken, init);
|
||||
if (!response.ok) {
|
||||
let detail = response.statusText;
|
||||
try {
|
||||
const body = await response.json();
|
||||
if (body?.message) detail = body.message;
|
||||
} catch {
|
||||
/* not JSON */
|
||||
}
|
||||
throw new ApiError(response.status, detail);
|
||||
}
|
||||
return (await response.json()) as T;
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { SHARED_MANIFEST } from "@/utils/shared-manifests.generated";
|
||||
|
||||
/**
|
||||
* SignalR message (target) names the backend can send — derived from the generated shared
|
||||
* manifest (source of truth: Constants.SignalRMessages in AutopilotMonitor.Shared).
|
||||
* SignalRContext types its on/off against this union, so subscribing to a name the backend
|
||||
* never sends fails tsc instead of silently never firing. Renaming a name backend-side
|
||||
* regenerates the manifest (SharedManifestParityTests) and breaks stale web literals here.
|
||||
*/
|
||||
export const SIGNALR_MESSAGES = SHARED_MANIFEST.signalRMessages;
|
||||
|
||||
export type SignalRMessageName = (typeof SIGNALR_MESSAGES)[number];
|
||||
@@ -7,7 +7,7 @@
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"prebuild": "node scripts/sync-error-codes.js && node scripts/generate-lastmod.js",
|
||||
"prebuild": "node scripts/sync-error-codes.js && node scripts/generate-lastmod.js && node scripts/generate-version.js",
|
||||
"sync-error-codes": "node scripts/sync-error-codes.js",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
/**
|
||||
* Generates public/version.json — the portal's build stamp, served verbatim at /version.json
|
||||
* by the static export. Runs in prebuild (locally and inside the Oryx build container).
|
||||
*
|
||||
* Commit precedence: GITHUB_SHA env (CI, passed through deploy-web.yml) → git rev-parse
|
||||
* (local dev; git is available in the Oryx container too, but the env var is authoritative)
|
||||
* → "unknown". The deploy-web workflow polls the deployed /version.json until `commit`
|
||||
* matches the pushed SHA — the same verify contract the backend has on /api/health.
|
||||
*
|
||||
* The file is gitignored: buildUtc changes every build, it must never be committed.
|
||||
*/
|
||||
|
||||
const { execSync } = require("child_process");
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
|
||||
const WEB_ROOT = path.resolve(__dirname, "..");
|
||||
const OUTPUT_FILE = path.join(WEB_ROOT, "public", "version.json");
|
||||
|
||||
function resolveCommit() {
|
||||
const envSha = process.env.GITHUB_SHA;
|
||||
if (envSha && /^[0-9a-f]{7,40}$/i.test(envSha)) {
|
||||
return envSha.substring(0, 7).toLowerCase();
|
||||
}
|
||||
try {
|
||||
const sha = execSync("git rev-parse --short=7 HEAD", {
|
||||
encoding: "utf-8",
|
||||
stdio: ["pipe", "pipe", "pipe"],
|
||||
}).trim();
|
||||
if (sha) return sha.toLowerCase();
|
||||
} catch {
|
||||
// fall through — a build without git and without GITHUB_SHA still succeeds
|
||||
}
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
const manifest = {
|
||||
component: "web",
|
||||
commit: resolveCommit(),
|
||||
buildUtc: new Date().toISOString(),
|
||||
};
|
||||
|
||||
fs.mkdirSync(path.dirname(OUTPUT_FILE), { recursive: true });
|
||||
fs.writeFileSync(OUTPUT_FILE, JSON.stringify(manifest, null, 2) + "\n", "utf-8");
|
||||
console.log(`Generated ${OUTPUT_FILE}: ${manifest.commit} @ ${manifest.buildUtc}`);
|
||||
@@ -22,6 +22,8 @@
|
||||
{ "route": "/changelog", "redirect": "https://docs.autopilotmonitor.com/changelog/platform-changelog", "statusCode": 301 },
|
||||
{ "route": "/roadmap", "redirect": "/", "statusCode": 302 },
|
||||
|
||||
{ "route": "/version.json", "headers": { "Cache-Control": "no-store" } },
|
||||
|
||||
{ "route": "/sessions/index.txt", "rewrite": "/sessions/index.txt" },
|
||||
{ "route": "/sessions/inspector/index.txt", "rewrite": "/sessions/inspector/index.txt" },
|
||||
{ "route": "/diagnosis/index.txt", "rewrite": "/diagnosis/index.txt" },
|
||||
|
||||
@@ -100,6 +100,29 @@ describe("session statuses", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("signalR message names", () => {
|
||||
// The real enforcement is compile-time: SignalRContext.on/off type their event names
|
||||
// against this manifest section (lib/signalrMessages.ts), so a web subscription to a
|
||||
// name the backend never sends fails tsc. These tests pin the section's shape so a
|
||||
// regeneration that drops or duplicates names is caught even before tsc runs.
|
||||
it("exists with the full backend catalog and no duplicates", () => {
|
||||
const names = SHARED_MANIFEST.signalRMessages;
|
||||
expect(names.length).toBeGreaterThanOrEqual(13);
|
||||
expect(new Set(names).size).toBe(names.length);
|
||||
// Session-lifecycle names every dashboard hook depends on:
|
||||
for (const required of ["newSession", "newevents", "eventStream", "sessionDeleted"]) {
|
||||
expect(names).toContain(required);
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps the single legacy lowercase name and camelCase for the rest", () => {
|
||||
for (const name of SHARED_MANIFEST.signalRMessages) {
|
||||
if (name === "newevents") continue; // persisted wire name predating the convention
|
||||
expect(name).toMatch(/^[a-z]+([A-Z][a-z]*)*$/);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("gather-rule event-type autocomplete", () => {
|
||||
it("contains no event type the agent/backend does not know", () => {
|
||||
// Typo guard: every autocomplete entry must be a real Constants.EventTypes value.
|
||||
|
||||
@@ -639,5 +639,20 @@ export const SHARED_MANIFEST = {
|
||||
"windows_update_reboot_pending",
|
||||
"windows_update_started",
|
||||
"windows_update_succeeded"
|
||||
],
|
||||
"signalRMessages": [
|
||||
"newSession",
|
||||
"newevents",
|
||||
"eventStream",
|
||||
"ruleResultsReady",
|
||||
"vulnerabilityReportReady",
|
||||
"sessionDeleted",
|
||||
"tenantNotification",
|
||||
"tenantNotificationDismissed",
|
||||
"tenantNotificationsDismissedAll",
|
||||
"globalNotification",
|
||||
"globalNotificationDismissed",
|
||||
"globalNotificationsDismissedAll",
|
||||
"accessRevoked"
|
||||
]
|
||||
} as const;
|
||||
|
||||
@@ -635,5 +635,20 @@
|
||||
"windows_update_reboot_pending",
|
||||
"windows_update_started",
|
||||
"windows_update_succeeded"
|
||||
],
|
||||
"signalRMessages": [
|
||||
"newSession",
|
||||
"newevents",
|
||||
"eventStream",
|
||||
"ruleResultsReady",
|
||||
"vulnerabilityReportReady",
|
||||
"sessionDeleted",
|
||||
"tenantNotification",
|
||||
"tenantNotificationDismissed",
|
||||
"tenantNotificationsDismissedAll",
|
||||
"globalNotification",
|
||||
"globalNotificationDismissed",
|
||||
"globalNotificationsDismissedAll",
|
||||
"accessRevoked"
|
||||
]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user