diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/PolicyEnforcementMiddlewareTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/PolicyEnforcementMiddlewareTests.cs
index 655a431f..f33caed1 100644
--- a/src/Backend/AutopilotMonitor.Functions.Tests/PolicyEnforcementMiddlewareTests.cs
+++ b/src/Backend/AutopilotMonitor.Functions.Tests/PolicyEnforcementMiddlewareTests.cs
@@ -1,12 +1,15 @@
using System.Security.Claims;
using AutopilotMonitor.Functions.Functions.Admin;
using AutopilotMonitor.Functions.Functions.Config;
+using AutopilotMonitor.Functions.Helpers;
using AutopilotMonitor.Functions.Middleware;
using AutopilotMonitor.Functions.Security;
using AutopilotMonitor.Functions.Services;
using AutopilotMonitor.Shared;
using AutopilotMonitor.Shared.DataAccess;
using AutopilotMonitor.Shared.Models;
+using Microsoft.AspNetCore.Http;
+using Microsoft.Azure.Functions.Worker;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging.Abstractions;
using Moq;
@@ -1123,6 +1126,126 @@ public class PolicyEnforcementMiddlewareTests
Assert.True(result.Allowed);
}
+ ///
+ /// Operator reads whose ?tenantId= is a handler-side filter for a Global Admin / Global Reader: session reports
+ /// (GA-only AdminNote, submitter contact data), rule submissions (contact e-mail, reviewer, review comment), a
+ /// tenant's MCP usage (its accounts and their tools) and verdict calibration (classifier diagnostics, drift
+ /// alerts). None of it is the managed tenant's telemetry, so a delegated (MSP) reader never reaches them.
+ ///
+ public static TheoryData OperatorFilterReads => new()
+ {
+ "/api/global/session-reports",
+ "/api/global/rule-submissions",
+ "/api/global/metrics/mcp-usage",
+ "/api/global/metrics/mcp-usage/daily",
+ "/api/global/metrics/verdict-calibration",
+ };
+
+ [Theory]
+ [MemberData(nameof(OperatorFilterReads))]
+ public async Task Delegated_OperatorFilterReads_WithManagedTenantId_AreForbidden(string path)
+ {
+ const string upn = "msp@partner.example";
+ var h = BuildHarness();
+ h.AsDelegated(TenantB);
+
+ var result = await h.Middleware.DecideAsync("GET", path, TenantB, AuthedPrincipal(TenantA, upn));
+
+ Assert.False(result.Allowed);
+ Assert.Equal(403, result.StatusCode);
+ }
+
+ [Theory]
+ [MemberData(nameof(OperatorFilterReads))]
+ public async Task GlobalReader_OperatorFilterReads_WithTenantFilter_StayAllowed(string path)
+ {
+ const string upn = "reader@vendor.example";
+ var h = BuildHarness();
+ h.AsGlobalRole(Constants.GlobalRoles.GlobalReader);
+
+ var result = await h.Middleware.DecideAsync("GET", path, TenantB, AuthedPrincipal(TenantA, upn));
+
+ Assert.True(result.Allowed);
+ Assert.True(result.Context!.IsGlobalReader);
+ }
+
+ // ── Invoke adapter: the tenantId the middleware authorizes is the one the handler reads ──
+ // Handlers read req.Query (HttpUtility.ParseQueryString), which comma-joins a repeated key. Deciding on the
+ // first value alone let ?tenantId=B&tenantId=C pass the cross-tenant check on B while the handler read "B,C".
+
+ private static async Task<(bool NextCalled, int StatusCode, RequestContext? Context)> InvokeAsync(
+ Harness h, string path, string queryString, ClaimsPrincipal principal)
+ {
+ var http = new DefaultHttpContext();
+ http.Request.Method = "GET";
+ http.Request.Path = path;
+ http.Request.QueryString = new QueryString(queryString);
+ http.Response.Body = new MemoryStream();
+ var items = new Dictionary { ["HttpRequestContext"] = http, ["ClaimsPrincipal"] = principal };
+ var context = new Mock();
+ context.SetupGet(c => c.Items).Returns(items);
+
+ var nextCalled = false;
+ await h.Middleware.Invoke(context.Object, _ => { nextCalled = true; return Task.CompletedTask; });
+ items.TryGetValue(RequestContext.ItemsKey, out var resolved);
+ return (nextCalled, http.Response.StatusCode, resolved as RequestContext);
+ }
+
+ [Fact]
+ public async Task Invoke_RepeatedTenantId_DelegatedReader_IsForbidden()
+ {
+ // B is managed, C is not — the handler would read "B,C", so the decision is taken on "B,C".
+ const string tenantC = "33333333-3333-3333-3333-333333333333";
+ var h = BuildHarness();
+ h.AsDelegated(TenantB);
+
+ var run = await InvokeAsync(h, "/api/global/metrics/fleet-health", $"?tenantId={TenantB}&tenantId={tenantC}",
+ AuthedPrincipal(TenantA, "msp@partner.example"));
+
+ Assert.False(run.NextCalled);
+ Assert.Equal(403, run.StatusCode);
+ }
+
+ [Fact]
+ public async Task Invoke_ManagedTenantId_DelegatedReader_IsAdmitted()
+ {
+ var h = BuildHarness();
+ h.AsDelegated(TenantB);
+
+ var run = await InvokeAsync(h, "/api/global/metrics/fleet-health", $"?tenantId={TenantB}",
+ AuthedPrincipal(TenantA, "msp@partner.example"));
+
+ Assert.True(run.NextCalled);
+ Assert.True(run.Context!.IsDelegatedReader);
+ Assert.Equal(TenantB, run.Context.TargetTenantId);
+ }
+
+ [Theory]
+ [InlineData("?tenantId=22222222-2222-2222-2222-222222222222")]
+ [InlineData("?tenantId=22222222-2222-2222-2222-222222222222&tenantId=33333333-3333-3333-3333-333333333333")]
+ [InlineData("?tenantid=22222222-2222-2222-2222-222222222222&TENANTID=33333333-3333-3333-3333-333333333333")]
+ [InlineData("?tenantId=&tenantId=33333333-3333-3333-3333-333333333333")]
+ // Encoded keys: %49 is 'I' on both paths. Only HttpUtility decodes the non-standard %u0049, but the handler
+ // parses Url.Query, where System.Uri has already escaped the stray '%' — neither path sees a tenantId.
+ [InlineData("?tenant%49d=22222222-2222-2222-2222-222222222222")]
+ [InlineData("?tenant%u0049d=22222222-2222-2222-2222-222222222222")]
+ public async Task Invoke_TargetTenantId_IsTheValueTheHandlerReads(string queryString)
+ {
+ const string path = "/api/global/metrics/fleet-health";
+ var h = BuildHarness();
+ h.AsGlobalRole(Constants.GlobalRoles.GlobalAdmin);
+
+ var run = await InvokeAsync(h, path, queryString, AuthedPrincipal(TenantA, "ga@vendor.example"));
+
+ // The handler's view: req.Query over the request Url (QueryParamGuardTests pins that implementation).
+ var handlerRequest = new Mock(Mock.Of()) { CallBase = true };
+ handlerRequest.SetupGet(r => r.Url).Returns(new Uri("https://localhost" + path + queryString));
+ var handlerTenantId = handlerRequest.Object.Query["tenantId"];
+
+ Assert.True(run.NextCalled);
+ Assert.Equal(string.IsNullOrWhiteSpace(handlerTenantId) ? TenantA : handlerTenantId, run.Context!.TargetTenantId);
+ }
+
[Fact]
public async Task Delegated_CustomsArchive_OnManagedTenant_IsForbidden_ExcludeDelegated()
{
@@ -1274,6 +1397,7 @@ public class PolicyEnforcementMiddlewareTests
[InlineData("GET", "/api/global/distress-reports", TenantScoping.None)]
// ops-events is platform-OPERATIONAL data — GA/Reader-only by intent (not a can't-bound limit), so None.
[InlineData("GET", "/api/global/ops-events", TenantScoping.None)]
+ // Operator reads with a ?tenantId= filter: OperatorFilterReads above.
public void GlobalRoute_DelegatedAccessibility_MatchesContract(string method, string path, TenantScoping expected)
{
var entry = EndpointAccessPolicyCatalog.FindPolicy(method, path);
@@ -1282,6 +1406,63 @@ public class PolicyEnforcementMiddlewareTests
Assert.Equal(expected, entry.TenantScoping);
}
+ // ── Catalog ratchet: every platform route a delegated (MSP) caller reaches is a deliberate entry ──
+ // QueryParam/RouteParam on a GlobalReadOrAdmin route admits a delegated reader of the named tenant, whether the
+ // author meant an authorization scope or only a handler-side filter — that is how session reports and a tenant's
+ // MCP accounts once reached MSP callers. A route belongs on this list only if its handler binds to the named
+ // tenant AND returns that tenant's own data: no operator notes or review comments, no contact data, no account
+ // lists, no platform-internal diagnostics. Anything else stays None (the handler can still read ?tenantId=
+ // itself) or carries excludeDelegated. The subset tier admits a delegated caller on the bounded aggregate.
+ private static readonly string[] DelegatedReachablePlatformRoutes =
+ {
+ "GET config/all",
+ "GET global/apps/analytics",
+ "GET global/apps/list",
+ "GET global/apps/sessions",
+ "GET global/audit/logs",
+ "GET global/metrics/agent-efficiency",
+ "GET global/metrics/app",
+ "GET global/metrics/device-journeys",
+ "GET global/metrics/fleet-health",
+ "GET global/metrics/geographic",
+ "GET global/metrics/geographic/sessions",
+ "GET global/metrics/rule-stats",
+ "GET global/metrics/sla",
+ "GET global/metrics/summary",
+ "GET global/metrics/time-attribution",
+ "GET global/metrics/usage",
+ "GET global/metrics/vulnerability",
+ "GET global/raw/events",
+ "GET global/raw/sessions",
+ "GET global/rules/analyze",
+ "GET global/rules/gather",
+ "GET global/search/sessions",
+ "GET global/search/sessions-by-cve",
+ "GET global/search/sessions-by-event",
+ "GET global/session-annotations",
+ "GET global/sessions",
+ "GET global/stats/sessions",
+ "GET vulnerability/software-inventory",
+ };
+
+ [Fact]
+ public void DelegatedReachablePlatformRoutes_AreTheSanctionedSet()
+ {
+ var reachable = EndpointAccessPolicyCatalog.Entries
+ .Where(e => e.Policy == EndpointPolicy.GlobalReadOrDelegatedSubset
+ || (e.Policy == EndpointPolicy.GlobalReadOrAdmin
+ && (e.TenantScoping is TenantScoping.QueryParam or TenantScoping.RouteParam)
+ && !e.ExcludeDelegated))
+ .Select(e => $"{e.HttpMethod} {e.RouteTemplate}")
+ .ToArray();
+
+ var added = reachable.Except(DelegatedReachablePlatformRoutes).ToArray();
+ var removed = DelegatedReachablePlatformRoutes.Except(reachable).ToArray();
+ Assert.True(added.Length == 0 && removed.Length == 0,
+ $"Delegated-reachable platform routes changed. Added: [{string.Join(", ", added)}] — decide per the rule " +
+ $"above (None / excludeDelegated, or add it to the list). Removed: [{string.Join(", ", removed)}].");
+ }
+
// ── Catalog contract: platform-operational GA/Reader-only reads carry ExcludeDelegated ──
// The None-equivalent for {tenantId}-RouteParam routes (which the {tenantId}-convention forbids from
// being None): GlobalReadOrAdmin keeps GA + read-only Reader, ExcludeDelegated removes the delegated rescue.
diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Reports/GetSessionReportsFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Reports/GetSessionReportsFunction.cs
index 673b8521..dc1ca893 100644
--- a/src/Backend/AutopilotMonitor.Functions/Functions/Reports/GetSessionReportsFunction.cs
+++ b/src/Backend/AutopilotMonitor.Functions/Functions/Reports/GetSessionReportsFunction.cs
@@ -31,7 +31,8 @@ namespace AutopilotMonitor.Functions.Functions.Reports
try
{
- // Authentication + GlobalAdminOnly authorization enforced by PolicyEnforcementMiddleware
+ // Authentication + GlobalReadOrAdmin authorization enforced by PolicyEnforcementMiddleware. The route is
+ // unscoped on purpose: ?tenantId= is a filter, and no delegated (MSP) reader may reach the AdminNote.
var callerTenantId = TenantHelper.GetTenantId(req);
var query = req.Query;
diff --git a/src/Backend/AutopilotMonitor.Functions/Middleware/PolicyEnforcementMiddleware.cs b/src/Backend/AutopilotMonitor.Functions/Middleware/PolicyEnforcementMiddleware.cs
index bb955f46..563068c3 100644
--- a/src/Backend/AutopilotMonitor.Functions/Middleware/PolicyEnforcementMiddleware.cs
+++ b/src/Backend/AutopilotMonitor.Functions/Middleware/PolicyEnforcementMiddleware.cs
@@ -72,7 +72,11 @@ public class PolicyEnforcementMiddleware : IFunctionsWorkerMiddleware
var requestPath = httpContext.Request.Path.Value ?? string.Empty;
var httpMethod = httpContext.Request.Method;
- var queryTenantId = httpContext.Request.Query["tenantId"].FirstOrDefault();
+ // Joined like the handlers' req.Query, which comma-joins a repeated key (empty values included): the
+ // cross-tenant decision is taken on the very string the handler reads, never on the first of several values.
+ var queryTenantId = httpContext.Request.Query.TryGetValue("tenantId", out var tenantIdValues)
+ ? string.Join(",", tenantIdValues.ToArray())
+ : null;
// Sanitized copies for logging only — never used for routing/authorization decisions.
var logPath = LogSanitizer.Clean(requestPath);
diff --git a/src/Backend/AutopilotMonitor.Functions/Security/EndpointAccessPolicyCatalog.cs b/src/Backend/AutopilotMonitor.Functions/Security/EndpointAccessPolicyCatalog.cs
index 66f9de9e..3d32216c 100644
--- a/src/Backend/AutopilotMonitor.Functions/Security/EndpointAccessPolicyCatalog.cs
+++ b/src/Backend/AutopilotMonitor.Functions/Security/EndpointAccessPolicyCatalog.cs
@@ -17,7 +17,11 @@ public enum TenantScoping
/// TenantId comes from {tenantId} route parameter. Middleware enforces cross-tenant check.
RouteParam,
- /// TenantId comes from ?tenantId= query parameter (optional, falls back to JWT tenant). Middleware enforces cross-tenant check.
+ ///
+ /// TenantId comes from ?tenantId= query parameter (optional, falls back to JWT tenant). Middleware enforces cross-tenant check.
+ /// On a delegated read tier (GlobalReadOrAdmin included) this also admits a delegated (MSP) reader of the named tenant —
+ /// a platform route whose ?tenantId= is only a handler-side filter stays .
+ ///
QueryParam,
}
@@ -557,7 +561,9 @@ public static class EndpointAccessPolicyCatalog
new("GET", "global/metrics/fleet-health", EndpointPolicy.GlobalReadOrAdmin, TenantScoping.QueryParam),
new("GET", "global/metrics/time-attribution", EndpointPolicy.GlobalReadOrAdmin, TenantScoping.QueryParam),
new("GET", "global/metrics/device-journeys", EndpointPolicy.GlobalReadOrAdmin, TenantScoping.QueryParam),
- new("GET", "global/metrics/verdict-calibration", EndpointPolicy.GlobalReadOrAdmin, TenantScoping.QueryParam),
+ // Operator-only classifier diagnostics and drift alerts; the handler reads ?tenantId= itself. None, so no
+ // delegated (MSP) reader reaches them.
+ new("GET", "global/metrics/verdict-calibration", EndpointPolicy.GlobalReadOrAdmin),
new("GET", "global/apps/list", EndpointPolicy.GlobalReadOrAdmin, TenantScoping.QueryParam),
new("GET", "global/apps/analytics", EndpointPolicy.GlobalReadOrAdmin, TenantScoping.QueryParam),
new("GET", "global/apps/sessions", EndpointPolicy.GlobalReadOrAdmin, TenantScoping.QueryParam),
@@ -567,19 +573,24 @@ public static class EndpointAccessPolicyCatalog
new("GET", "global/metrics/sla", EndpointPolicy.GlobalReadOrAdmin, TenantScoping.QueryParam),
new("GET", "global/metrics/rule-stats", EndpointPolicy.GlobalReadOrAdmin, TenantScoping.QueryParam),
new("GET", "global/metrics/vulnerability", EndpointPolicy.GlobalReadOrAdmin, TenantScoping.QueryParam),
- new("GET", "global/metrics/mcp-usage", EndpointPolicy.GlobalReadOrAdmin, TenantScoping.QueryParam),
- new("GET", "global/metrics/mcp-usage/daily", EndpointPolicy.GlobalReadOrAdmin, TenantScoping.QueryParam),
- // One tenant's organization usage by account (?tenantId= read by the handler): deliberately None, not
- // QueryParam — the route enumerates accounts (UPNs), which the delegated path must never reach.
+ // A named tenant's MCP use — its accounts (UPNs) and their tools, the daily counts, the organization view.
+ // The handlers read ?tenantId= themselves: deliberately None, not QueryParam, so the delegated path never
+ // reaches them.
+ new("GET", "global/metrics/mcp-usage", EndpointPolicy.GlobalReadOrAdmin),
+ new("GET", "global/metrics/mcp-usage/daily", EndpointPolicy.GlobalReadOrAdmin),
new("GET", "global/metrics/mcp-usage/organization", EndpointPolicy.GlobalReadOrAdmin),
new("GET", "global/distress-reports", EndpointPolicy.GlobalReadOrAdmin),
- new("GET", "global/session-reports", EndpointPolicy.GlobalReadOrAdmin, TenantScoping.QueryParam),
+ // The operator's report queue: GA-only AdminNote, the submitter's comment and contact data. ?tenantId= is a
+ // filter the handler reads itself; None, so no delegated reader reaches it.
+ new("GET", "global/session-reports", EndpointPolicy.GlobalReadOrAdmin),
// Mints the HMAC ticket for the anonymous proxied stream above (the MCP's report download).
new("POST", "global/session-reports/download-ticket", EndpointPolicy.GlobalReadOrAdmin),
new("GET", "global/session-reports/download-url", EndpointPolicy.GlobalReadOrAdmin),
new("PATCH", "global/session-reports/{reportId}/note", EndpointPolicy.GlobalAdminOnly),
// Community rule submissions, operator side: cross-tenant read (Global Reader included), decision GA-only.
- new("GET", "global/rule-submissions", EndpointPolicy.GlobalReadOrAdmin, TenantScoping.QueryParam),
+ // The list carries the contact e-mail and the review; ?tenantId= is a filter the handler reads itself, None
+ // so no delegated reader reaches it (the tenant's own list, rules/submissions, is admin-only).
+ new("GET", "global/rule-submissions", EndpointPolicy.GlobalReadOrAdmin),
new("GET", "global/rule-submissions/{submissionId}", EndpointPolicy.GlobalReadOrAdmin),
new("PATCH", "global/rule-submissions/{submissionId}", EndpointPolicy.GlobalAdminOnly),
new("DELETE", "global/rule-submissions/{submissionId}", EndpointPolicy.GlobalAdminOnly),
diff --git a/src/Web/autopilot-monitor-web/app/analyze-rules/page.tsx b/src/Web/autopilot-monitor-web/app/analyze-rules/page.tsx
index d0a04649..4f8223ff 100644
--- a/src/Web/autopilot-monitor-web/app/analyze-rules/page.tsx
+++ b/src/Web/autopilot-monitor-web/app/analyze-rules/page.tsx
@@ -43,7 +43,7 @@ import { fetchJson, jsonBody } from "@/lib/apiClient";
import { useWindowDays } from "@/hooks/useWindowDays";
import { utcDateDaysAgo } from "@/lib/timeWindow";
import { CommunityContributionBox } from "@/components/rules/CommunityContributionBox";
-import { MySubmissionsList } from "@/components/rules/MySubmissionsList";
+import { MySubmissionsList, isSubmissionsAdminOnly } from "@/components/rules/MySubmissionsList";
import { SubmitRulesModal } from "@/components/rules/SubmitRulesModal";
/** Rule telemetry window without `?days=` — the backend's own rule-stats default (today−30 … today). */
@@ -133,9 +133,7 @@ function AnalyzeRulesContent() {
// A read-only Global Reader — and an own-tenant admin viewing a FOREIGN tenant (cross-tenant override) —
// is read-only. Backend also enforces (rules write is TenantAdminOrGA, cross-tenant blocked for non-GA).
const isReadOnly = !(user?.isGlobalAdmin || (user?.isTenantAdmin && !isGlobalOverride));
- // Community submissions are TenantAdminOrGA on the own tenant; only a global-scope override reads them through
- // the operator list. Everyone else (Operator, Viewer) gets a hint instead of a 403 on the list call.
- const submissionsAdminOnly = isReadOnly && !isGlobalOverride;
+ const submissionsAdminOnly = isSubmissionsAdminOnly(isReadOnly, scope);
// Cross-tenant write routing: in a Global Admin override the JWT-scoped rules/analyze/{id} route
// resolves the tenant from the caller's token and would silently upsert into the GA's OWN tenant
diff --git a/src/Web/autopilot-monitor-web/app/gather-rules/page.tsx b/src/Web/autopilot-monitor-web/app/gather-rules/page.tsx
index ef2d19ad..2e5d5b55 100644
--- a/src/Web/autopilot-monitor-web/app/gather-rules/page.tsx
+++ b/src/Web/autopilot-monitor-web/app/gather-rules/page.tsx
@@ -25,7 +25,7 @@ import { DocsLink } from "@/components/DocsLink";
import type { TenantFeatureFlagsResponse } from "@/lib/generated/wire-types.generated";
import { fetchJson } from "@/lib/apiClient";
import { CommunityContributionBox } from "@/components/rules/CommunityContributionBox";
-import { MySubmissionsList } from "@/components/rules/MySubmissionsList";
+import { MySubmissionsList, isSubmissionsAdminOnly } from "@/components/rules/MySubmissionsList";
import { SubmitRulesModal } from "@/components/rules/SubmitRulesModal";
export default function GatherRulesPage() {
@@ -83,9 +83,7 @@ export default function GatherRulesPage() {
// A read-only Global Reader — and an own-tenant admin viewing a FOREIGN tenant (cross-tenant override) —
// is read-only. Backend also enforces (rules write is TenantAdminOrGA, cross-tenant blocked for non-GA).
const isReadOnly = !(user?.isGlobalAdmin || (user?.isTenantAdmin && !isGlobalOverride));
- // Community submissions are TenantAdminOrGA on the own tenant; only a global-scope override reads them through
- // the operator list. Everyone else (Operator, Viewer) gets a hint instead of a 403 on the list call.
- const submissionsAdminOnly = isReadOnly && !isGlobalOverride;
+ const submissionsAdminOnly = isSubmissionsAdminOnly(isReadOnly, scope);
// Cross-tenant write routing: in a Global Admin override the JWT-scoped rules/gather/{id} route
// resolves the tenant from the caller's token and would silently upsert into the GA's OWN tenant
diff --git a/src/Web/autopilot-monitor-web/components/rules/MySubmissionsList.tsx b/src/Web/autopilot-monitor-web/components/rules/MySubmissionsList.tsx
index d1c13dd7..c6b8b053 100644
--- a/src/Web/autopilot-monitor-web/components/rules/MySubmissionsList.tsx
+++ b/src/Web/autopilot-monitor-web/components/rules/MySubmissionsList.tsx
@@ -30,6 +30,18 @@ export function submissionStatusBadge(status: string): { label: string; cls: str
}
}
+/**
+ * Whether a rule page shows the admin-only hint instead of the list. Submissions are TenantAdminOrGA on the own
+ * tenant, and only a platform-scope (Global Admin / Global Reader) override reads a foreign tenant's through the
+ * operator list — a delegated (MSP) reader gets the hint like an Operator or Viewer instead of a 403.
+ */
+export function isSubmissionsAdminOnly(
+ isReadOnly: boolean,
+ scope: { isGlobalOverride: boolean; isDelegatedScope: boolean },
+): boolean {
+ return isReadOnly && !(scope.isGlobalOverride && !scope.isDelegatedScope);
+}
+
/**
* The tenant's own community submissions of one rule kind. Pending rows are the ones an admin
* wants to see, so they show by default; decided and withdrawn rows sit behind "Show all". Renders
diff --git a/src/Web/autopilot-monitor-web/components/rules/__tests__/isSubmissionsAdminOnly.test.ts b/src/Web/autopilot-monitor-web/components/rules/__tests__/isSubmissionsAdminOnly.test.ts
new file mode 100644
index 00000000..d834f26b
--- /dev/null
+++ b/src/Web/autopilot-monitor-web/components/rules/__tests__/isSubmissionsAdminOnly.test.ts
@@ -0,0 +1,21 @@
+import { describe, it, expect } from "vitest";
+import { isSubmissionsAdminOnly } from "../MySubmissionsList";
+
+describe("isSubmissionsAdminOnly", () => {
+ it("lists a foreign tenant's submissions for a platform-scope override", () => {
+ expect(isSubmissionsAdminOnly(true, { isGlobalOverride: true, isDelegatedScope: false })).toBe(false);
+ });
+
+ it("shows a delegated (MSP) reader the hint on a managed tenant — the operator list is closed to them", () => {
+ expect(isSubmissionsAdminOnly(true, { isGlobalOverride: true, isDelegatedScope: true })).toBe(true);
+ });
+
+ it("shows an own-tenant Operator or Viewer the hint", () => {
+ expect(isSubmissionsAdminOnly(true, { isGlobalOverride: false, isDelegatedScope: false })).toBe(true);
+ });
+
+ it("lists the own tenant's submissions for its admin, also one who is a delegated reader elsewhere", () => {
+ expect(isSubmissionsAdminOnly(false, { isGlobalOverride: false, isDelegatedScope: false })).toBe(false);
+ expect(isSubmissionsAdminOnly(false, { isGlobalOverride: false, isDelegatedScope: true })).toBe(false);
+ });
+});