security: assume-breach alarm for the Global-Admin-only surface (x29 phase 0)

The 2026-09-04 audit found no bypass of the GA boundary, but a failed attempt left
only a Warning trace without identity, and a non-GA tools/call for a GA-only MCP tool
never reached the backend at all. This makes the boundary observable without adding
a second alarm writer (D-185):

Backend
- PrivilegedRouteDenied (Security): raised from the policy middleware's deny path for
  every 403 by an authenticated caller on a GlobalAdminOnly route. Critical for a
  caller without a platform role, Warning for a Global Reader. Details carry upn, oid,
  tid, client source, MCP tool name, correlation id, reason and policy. Throttled one
  hour per caller+path per instance (PrivilegedDenialReporter, IPrivilegedDenialReporter
  seam). PolicyResult now surfaces the denied tier; the DENIED log line gains oid, tid,
  clientSource and mcpTool.
- GET global/raw/access-probe: no-op GlobalAdminOnly route whose denial IS the signal
  (AccessProbeResponse DTO, catalog entry, parity test).
- Defense in depth: AppInsightsQueryFunction and both TableQueryFunction triggers re-check
  IsGlobalAdmin in the body (RawGlobalAdminGate); an empty RequestContext fails closed.
- Dual-register: OpsEventTypes.All, OpsAlertRulesSection.tsx, regenerated manifests.

MCP
- ga-tool-probe.ts: before dispatch, a caller without the GlobalAdmin role naming a
  strictGa tool leaves an unconditional [mcp-security] line (tool, upn, scope) and fires
  the access probe with X-MCP-Tool-Name; the SDK answer stays "Tool not found".
- GA_STRICT_TOOL_NAMES exported and pinned equal to the registered GA-minus-Reader set.
- debug-session prompt names query_backend_logs/query_table only for strictGa (Readers
  were told to call tools they cannot see); tool_call_rejected lines carry scope + upn;
  config-tool comment corrected against the actual catalog tiers.

Tests: reporter (severity, details, throttle, fail-soft), middleware policy surfacing and
IsPrivilegedDenial/BuildPrivilegedDenial, Program.cs middleware order source scan, raw
gate 403, probe catalog + JWT pins, wire parity; MCP probe unit tests, Global Reader over
HTTP on both eras, runtime refusal fires the probe, prompt gate.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Oliver Kieselbach
2026-09-04 13:29:06 +02:00
parent f0a3f04398
commit 729ee56bde
37 changed files with 1119 additions and 27 deletions
@@ -80,7 +80,8 @@ public class AdminIdentityBindingAuthorizationTests
{
Middleware = new PolicyEnforcementMiddleware(
NullLogger<PolicyEnforcementMiddleware>.Instance, globalAdmin, delegatedAdmin,
new TenantMemberRoleResolver(tenantAdmins, tenantConfig), tenantConfig),
new TenantMemberRoleResolver(tenantAdmins, tenantConfig), tenantConfig,
new RecordingDenialReporter()),
Repo = repo,
};
}
@@ -51,6 +51,8 @@ public class AppInsightsQueryFunctionTests
[InlineData("POST", "/api/global/raw/logs")]
[InlineData("GET", "/api/global/raw/tables")]
[InlineData("GET", "/api/global/raw/tables/TenantConfiguration")]
// The MCP's assume-breach probe: its whole value is that a non-GA caller is DENIED here.
[InlineData("GET", "/api/global/raw/access-probe")]
public void Raw_family_endpoints_are_GlobalAdminOnly_not_global_reader(string method, string path)
{
var entry = EndpointAccessPolicyCatalog.FindPolicy(method, path);
@@ -70,6 +72,14 @@ public class AppInsightsQueryFunctionTests
Assert.False(AuthenticationMiddleware.SkipsJwtValidation("POST", LogsRoute));
}
// ── The access probe must sit behind the SAME JWT gate: an anonymous hit is a 401 (no probe
// event), only an authenticated non-GA caller reaches the 403 that raises PrivilegedRouteDenied. ──
[Fact]
public void AccessProbe_requires_jwt_and_is_not_exempt()
{
Assert.False(AuthenticationMiddleware.SkipsJwtValidation("GET", "/api/global/raw/access-probe"));
}
// ── Verb binding: the KQL proxy is POST-only. A GET to the same path is unregistered and therefore
// fail-closed (FindPolicy == null → the middleware denies). Guards against a future GET alias that
// would carry the KQL in the query string (and into logs/URLs). ──
@@ -0,0 +1,60 @@
using System.IO;
using AutopilotMonitor.Functions.Middleware;
using Xunit;
namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// The catalog and <see cref="PolicyEnforcementMiddleware"/> are the authorization gate for every
/// HTTP route, and the raw table/log proxies rely on the <c>RequestContext</c> it resolves. Nothing
/// at runtime would notice if the registration in <c>Program.cs</c> were dropped or moved in front of
/// <see cref="AuthenticationMiddleware"/> (the policy step reads the principal the auth step stores) —
/// the routes would simply open up. This source scan pins the registration and its order.
/// </summary>
public class MiddlewarePipelineOrderTests
{
private static string RepoRoot()
{
var dir = new DirectoryInfo(System.AppContext.BaseDirectory);
while (dir != null && !File.Exists(Path.Combine(dir.FullName, "AutopilotMonitor.sln")))
dir = dir.Parent;
Assert.NotNull(dir);
return dir!.FullName;
}
private static string ProgramSource() => File.ReadAllText(Path.Combine(
RepoRoot(), "src", "Backend", "AutopilotMonitor.Functions", "Program.cs"));
[Fact]
public void Authentication_then_policy_enforcement_are_registered_in_that_order()
{
var src = ProgramSource();
var auth = src.IndexOf($"UseMiddleware<{nameof(AuthenticationMiddleware)}>()", System.StringComparison.Ordinal);
var policy = src.IndexOf($"UseMiddleware<{nameof(PolicyEnforcementMiddleware)}>()", System.StringComparison.Ordinal);
var rateLimit = src.IndexOf($"UseMiddleware<{nameof(UserRateLimitMiddleware)}>()", System.StringComparison.Ordinal);
Assert.True(auth >= 0, "AuthenticationMiddleware is not registered in Program.cs");
Assert.True(policy >= 0, "PolicyEnforcementMiddleware is not registered in Program.cs");
Assert.True(auth < policy, "PolicyEnforcementMiddleware must run AFTER AuthenticationMiddleware (it reads the stored principal)");
Assert.True(rateLimit < 0 || policy < rateLimit, "UserRateLimitMiddleware keys off the RequestContext the policy step resolves");
}
[Fact]
public void Each_gate_middleware_is_registered_exactly_once()
{
var src = ProgramSource();
Assert.Equal(1, Count(src, $"UseMiddleware<{nameof(AuthenticationMiddleware)}>()"));
Assert.Equal(1, Count(src, $"UseMiddleware<{nameof(PolicyEnforcementMiddleware)}>()"));
}
private static int Count(string haystack, string needle)
{
var count = 0;
for (var i = haystack.IndexOf(needle, System.StringComparison.Ordinal); i >= 0;
i = haystack.IndexOf(needle, i + needle.Length, System.StringComparison.Ordinal))
count++;
return count;
}
}
@@ -119,11 +119,129 @@ public class PolicyEnforcementMiddlewareTests
var mw = new PolicyEnforcementMiddleware(
NullLogger<PolicyEnforcementMiddleware>.Instance, globalAdmin, delegatedAdmin,
new TenantMemberRoleResolver(tenantAdmins, tenantConfig), tenantConfig);
new TenantMemberRoleResolver(tenantAdmins, tenantConfig), tenantConfig,
new RecordingDenialReporter());
return new Harness { Middleware = mw, Repo = repo, ConfigRepo = configRepo };
}
// ── ASSUME-BREACH: which denials are reported as PrivilegedRouteDenied ──────────────────
// The deny path in Invoke reports iff the decision denied a GlobalAdminOnly route with 403 by an
// authenticated caller. DecideAsync is the seam: it must surface the denied tier on the result.
[Fact]
public async Task GlobalAdminOnly_denial_of_tenant_user_carries_the_policy_and_is_privileged()
{
var h = BuildHarness();
h.AsTenantAdmin(TenantA, "admin@contoso.com");
var r = await h.Middleware.DecideAsync("POST", "/api/global/raw/logs", null, AuthedPrincipal(TenantA, "admin@contoso.com"));
Assert.False(r.Allowed);
Assert.Equal(403, r.StatusCode);
Assert.Equal(EndpointPolicy.GlobalAdminOnly, r.Policy);
Assert.Equal("NotGlobalAdmin", r.LogReason);
Assert.True(PolicyEnforcementMiddleware.IsPrivilegedDenial(r));
}
[Fact]
public async Task GlobalReader_on_raw_table_is_denied_and_privileged()
{
var h = BuildHarness();
h.AsGlobalRole(Constants.GlobalRoles.GlobalReader);
var r = await h.Middleware.DecideAsync("GET", "/api/global/raw/tables/TenantConfiguration", null, AuthedPrincipal(TenantA, "reader@contoso.com"));
Assert.False(r.Allowed);
Assert.Equal(403, r.StatusCode);
Assert.Equal(EndpointPolicy.GlobalAdminOnly, r.Policy);
Assert.True(PolicyEnforcementMiddleware.IsPrivilegedDenial(r));
}
[Fact]
public async Task GlobalAdmin_on_access_probe_is_allowed_and_not_privileged()
{
var h = BuildHarness();
h.AsGlobalRole(Constants.GlobalRoles.GlobalAdmin);
var r = await h.Middleware.DecideAsync("GET", "/api/global/raw/access-probe", null, AuthedPrincipal(TenantA, "ga@contoso.com"));
Assert.True(r.Allowed);
Assert.True(r.Context!.IsGlobalAdmin);
Assert.False(PolicyEnforcementMiddleware.IsPrivilegedDenial(r));
}
[Fact]
public async Task Anonymous_on_GlobalAdminOnly_route_is_401_not_a_privileged_denial()
{
var h = BuildHarness();
var r = await h.Middleware.DecideAsync("GET", "/api/global/raw/access-probe", null, principal: null);
Assert.False(r.Allowed);
Assert.Equal(401, r.StatusCode);
Assert.Equal(EndpointPolicy.GlobalAdminOnly, r.Policy);
Assert.False(PolicyEnforcementMiddleware.IsPrivilegedDenial(r));
}
[Fact]
public async Task Unregistered_route_has_no_policy_and_is_not_privileged()
{
var h = BuildHarness();
var r = await h.Middleware.DecideAsync("GET", "/api/global/raw/does-not-exist", null, AuthedPrincipal(TenantA, "x@contoso.com"));
Assert.False(r.Allowed);
Assert.Null(r.Policy);
Assert.False(PolicyEnforcementMiddleware.IsPrivilegedDenial(r));
}
[Fact]
public async Task Member_route_denial_is_not_privileged()
{
var h = BuildHarness();
// No membership anywhere → MemberRead route denies with 403, but it is not the GA-only surface.
var r = await h.Middleware.DecideAsync("GET", $"/api/config/{TenantA}", null, AuthedPrincipal(TenantA, "nobody@contoso.com"));
Assert.False(r.Allowed);
Assert.NotEqual(EndpointPolicy.GlobalAdminOnly, r.Policy);
Assert.False(PolicyEnforcementMiddleware.IsPrivilegedDenial(r));
}
[Fact]
public async Task BuildPrivilegedDenial_projects_claims_headers_role_and_correlation()
{
var h = BuildHarness();
h.AsTenantAdmin(TenantA, "admin@contoso.com");
var principal = AuthedPrincipal(TenantA, "admin@contoso.com");
var r = await h.Middleware.DecideAsync("POST", "/api/global/raw/logs", null, principal);
var d = PolicyEnforcementMiddleware.BuildPrivilegedDenial(
r, "POST", "/api/global/raw/logs", principal, globalRole: null,
clientSource: "mcp", mcpToolName: "query_backend_logs", correlationId: "corr-42");
Assert.Equal("POST", d.Method);
Assert.Equal("/api/global/raw/logs", d.Path);
Assert.Equal(403, d.StatusCode);
Assert.Equal("NotGlobalAdmin", d.Reason);
Assert.Equal("GlobalAdminOnly", d.Policy);
Assert.Equal("admin@contoso.com", d.Upn);
Assert.Equal(TestOid, d.ObjectId);
Assert.Equal(TenantA, d.TenantId);
Assert.Equal("None", d.CallerRole);
Assert.Equal("mcp", d.ClientSource);
Assert.Equal("query_backend_logs", d.McpToolName);
Assert.Equal("corr-42", d.CorrelationId);
Assert.False(string.IsNullOrEmpty(d.CallerId));
var reader = PolicyEnforcementMiddleware.BuildPrivilegedDenial(
r, "POST", "/api/global/raw/logs", principal, globalRole: Constants.GlobalRoles.GlobalReader,
clientSource: null, mcpToolName: null, correlationId: "");
Assert.Equal(Constants.GlobalRoles.GlobalReader, reader.CallerRole);
Assert.Null(reader.ClientSource);
Assert.Null(reader.CorrelationId);
}
private static ClaimsPrincipal AuthedPrincipal(string tenantId, string upn, string objectId = TestOid)
=> new(new ClaimsIdentity(new[] { new Claim("tid", tenantId), new Claim("upn", upn), new Claim("oid", objectId) }, "TestAuth"));
@@ -0,0 +1,147 @@
using System.Collections.Generic;
using System.Linq;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using AutopilotMonitor.Functions.Services;
using AutopilotMonitor.Shared;
using AutopilotMonitor.Shared.DataAccess;
using AutopilotMonitor.Shared.Models;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging.Abstractions;
using Moq;
using Xunit;
namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// The assume-breach alarm for the Global-Admin-only surface: a refused GlobalAdminOnly request must
/// land as a <c>PrivilegedRouteDenied</c> Security ops event that carries the caller's identity and
/// the MCP tool name, Critical for an outsider and Warning for a known Global Reader, throttled per
/// caller+path so a scripted probe cannot flood the push channel.
/// </summary>
public class PrivilegedDenialReporterTests
{
private static (PrivilegedDenialReporter Reporter, List<OpsEventEntry> Saved) Rig(bool repoThrows = false)
{
var saved = new List<OpsEventEntry>();
var opsRepo = new Mock<IOpsEventRepository>();
var setup = opsRepo.Setup(r => r.SaveOpsEventAsync(It.IsAny<OpsEventEntry>()));
if (repoThrows)
setup.ThrowsAsync(new System.InvalidOperationException("table down"));
else
setup.Callback<OpsEventEntry>(e => { lock (saved) saved.Add(e); }).Returns(Task.CompletedTask);
var adminConfig = new Mock<AdminConfigurationService>(
Mock.Of<IConfigRepository>(), NullLogger<AdminConfigurationService>.Instance,
new MemoryCache(new MemoryCacheOptions())) { CallBase = false };
var opsEvents = new OpsEventService(opsRepo.Object, NullLogger<OpsEventService>.Instance,
TestNotifications.InertOpsAlertDispatch(adminConfig.Object));
var reporter = new PrivilegedDenialReporter(opsEvents, new MemoryCache(new MemoryCacheOptions()),
NullLogger<PrivilegedDenialReporter>.Instance);
return (reporter, saved);
}
private static PrivilegedDenial Denial(
string path = "/api/global/raw/logs", string callerRole = "None", string callerId = "oid-1",
string? clientSource = "mcp", string? mcpTool = "query_backend_logs")
=> new(
Method: "POST", Path: path, StatusCode: 403, Reason: "NotGlobalAdmin", Policy: "GlobalAdminOnly",
CallerId: callerId, Upn: "user@contoso.com", ObjectId: "oid-1",
TenantId: "11111111-1111-1111-1111-111111111111", CallerRole: callerRole,
ClientSource: clientSource, McpToolName: mcpTool, CorrelationId: "corr-1");
/// <summary>The write is fire-and-forget; give the completed-task continuation a moment.</summary>
private static List<OpsEventEntry> Settle(List<OpsEventEntry> saved, int expected)
{
var spin = new SpinWait();
var deadline = System.DateTime.UtcNow.AddSeconds(2);
while (System.DateTime.UtcNow < deadline)
{
lock (saved) if (saved.Count >= expected) break;
spin.SpinOnce();
}
lock (saved) return saved.ToList();
}
[Fact]
public void Outsider_lands_as_Critical_security_event_with_identity_and_tool()
{
var (reporter, saved) = Rig();
reporter.Report(Denial());
var evt = Assert.Single(Settle(saved, 1));
Assert.Equal(OpsEventCategory.Security, evt.Category);
Assert.Equal(OpsEventTypes.PrivilegedRouteDenied, evt.EventType);
Assert.Equal(OpsEventSeverity.Critical, evt.Severity);
Assert.Equal("11111111-1111-1111-1111-111111111111", evt.TenantId);
Assert.Equal("user@contoso.com", evt.UserId);
Assert.Contains("/api/global/raw/logs", evt.Message);
Assert.Contains("query_backend_logs", evt.Message);
using var details = JsonDocument.Parse(evt.Details!);
var d = details.RootElement;
Assert.Equal("oid-1", d.GetProperty("oid").GetString());
Assert.Equal("11111111-1111-1111-1111-111111111111", d.GetProperty("tid").GetString());
Assert.Equal("mcp", d.GetProperty("clientSource").GetString());
Assert.Equal("query_backend_logs", d.GetProperty("mcpToolName").GetString());
Assert.Equal("corr-1", d.GetProperty("correlationId").GetString());
Assert.Equal("GlobalAdminOnly", d.GetProperty("policy").GetString());
Assert.Equal("NotGlobalAdmin", d.GetProperty("reason").GetString());
}
[Fact]
public void Global_reader_lands_as_Warning_not_Critical()
{
var (reporter, saved) = Rig();
reporter.Report(Denial(callerRole: Constants.GlobalRoles.GlobalReader));
var evt = Assert.Single(Settle(saved, 1));
Assert.Equal(OpsEventSeverity.Warning, evt.Severity);
Assert.Equal(OpsEventTypes.PrivilegedRouteDenied, evt.EventType);
}
[Fact]
public void Direct_http_probe_without_mcp_headers_still_lands()
{
var (reporter, saved) = Rig();
reporter.Report(Denial(clientSource: null, mcpTool: null));
var evt = Assert.Single(Settle(saved, 1));
Assert.Contains("via direct", evt.Message);
using var details = JsonDocument.Parse(evt.Details!);
// Absent-when-null is the wire policy for API responses, but ops-event details use the default
// serializer: the keys are present with null so a reader can tell "no header" from "not recorded".
Assert.Equal(JsonValueKind.Null, details.RootElement.GetProperty("mcpToolName").ValueKind);
}
[Fact]
public void Same_caller_and_path_is_throttled_but_another_path_is_not()
{
var (reporter, saved) = Rig();
reporter.Report(Denial());
reporter.Report(Denial(mcpTool: "query_table")); // same path → throttled
reporter.Report(Denial(path: "/api/global/raw/tables")); // new path → reported
reporter.Report(Denial(callerId: "oid-2")); // new caller → reported
var events = Settle(saved, 3);
Assert.Equal(3, events.Count);
Assert.Contains(events, e => e.Message.Contains("/api/global/raw/tables"));
// The second attempt on the same (caller, path) carried a different tool name and was swallowed.
Assert.DoesNotContain(events, e => e.Message.Contains("query_table"));
Assert.Equal(2, events.Count(e => e.Message.Contains("/api/global/raw/logs")));
}
[Fact]
public void Repository_failure_never_escapes_the_deny_path()
{
var (reporter, _) = Rig(repoThrows: true);
var ex = Record.Exception(() => reporter.Report(Denial()));
Assert.Null(ex);
}
}
@@ -0,0 +1,83 @@
using System.Collections.Generic;
using System.IO;
using System.Net;
using System.Threading.Tasks;
using AutopilotMonitor.Functions.Functions.Raw;
using AutopilotMonitor.Functions.Helpers;
using Azure.Core.Serialization;
using Microsoft.Azure.Functions.Worker;
using Microsoft.Azure.Functions.Worker.Http;
using Microsoft.Extensions.DependencyInjection;
using Moq;
using Xunit;
namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// Defense-in-depth re-check inside the raw table/log proxies and the access probe. The catalog +
/// policy middleware are the gate; this pins that the in-body check fails CLOSED when the middleware
/// did not run (empty <see cref="RequestContext"/>) or resolved a non-GA caller, and stays silent for
/// a resolved Global Admin.
/// </summary>
public class RawGlobalAdminGateTests
{
private static (HttpRequestData Req, FunctionContext Ctx) BuildRequest(RequestContext? requestContext)
{
var services = new ServiceCollection();
services.AddOptions();
services.Configure<WorkerOptions>(o => o.Serializer = new JsonObjectSerializer(ApiJsonOptions.Create()));
var provider = services.BuildServiceProvider();
var items = new Dictionary<object, object>();
if (requestContext != null)
items[RequestContext.ItemsKey] = requestContext;
var context = new Mock<FunctionContext>();
context.SetupGet(c => c.Items).Returns(items);
context.SetupGet(c => c.InstanceServices).Returns(provider);
var req = new Mock<HttpRequestData>(context.Object);
req.SetupGet(r => r.Headers).Returns(new HttpHeadersCollection());
req.Setup(r => r.CreateResponse()).Returns(() => new FakeHttpResponseData(context.Object));
return (req.Object, context.Object);
}
[Fact]
public async Task Middleware_never_ran_empty_context_is_403()
{
var (req, ctx) = BuildRequest(requestContext: null);
var denied = await RawGlobalAdminGate.DenyUnlessGlobalAdminAsync(req, ctx);
Assert.NotNull(denied);
Assert.Equal(HttpStatusCode.Forbidden, denied!.StatusCode);
}
[Fact]
public async Task Resolved_non_global_admin_is_403()
{
var (req, ctx) = BuildRequest(new RequestContext { IsGlobalAdmin = false, IsGlobalReader = true });
var denied = await RawGlobalAdminGate.DenyUnlessGlobalAdminAsync(req, ctx);
Assert.NotNull(denied);
Assert.Equal(HttpStatusCode.Forbidden, denied!.StatusCode);
}
[Fact]
public async Task Resolved_global_admin_passes()
{
var (req, ctx) = BuildRequest(new RequestContext { IsGlobalAdmin = true });
Assert.Null(await RawGlobalAdminGate.DenyUnlessGlobalAdminAsync(req, ctx));
}
private sealed class FakeHttpResponseData : HttpResponseData
{
public FakeHttpResponseData(FunctionContext context) : base(context) { }
public override HttpStatusCode StatusCode { get; set; } = HttpStatusCode.OK;
public override HttpHeadersCollection Headers { get; set; } = new();
public override Stream Body { get; set; } = new MemoryStream();
public override HttpCookies Cookies { get; } = new Mock<HttpCookies>().Object;
}
}
@@ -140,6 +140,18 @@ public class RawWireParityTests
new ListRawTablesResponse { Count = tables.Count, Tables = tables });
}
// ── AccessProbeFunction — GET /api/global/raw/access-probe ───────────────────────────
// New route (no anonymous predecessor): the wire shape is pinned as fixed JSON.
[Fact]
public void AccessProbe_wire_shape()
{
var json = JsonSerializer.Serialize(
new AccessProbeResponse { Success = true, Role = "GlobalAdmin" }, WireOptions);
Assert.Equal("{\"success\":true,\"role\":\"GlobalAdmin\"}", json);
}
// ── TableQueryFunction.QueryTable — GET /api/global/raw/tables/{tableName} ───────────
// Old literal: new { table = actualTableName, count = entities.Count, entities, nextLink }
// (entities is List<Dictionary<string, object?>> at the call site.)
@@ -0,0 +1,19 @@
using System.Collections.Generic;
using AutopilotMonitor.Functions.Services;
namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// Recording stand-in for <see cref="IPrivilegedDenialReporter"/> so middleware tests can build a
/// <c>PolicyEnforcementMiddleware</c> without the ops-event/notification stack and still assert
/// what the deny path would have reported.
/// </summary>
internal sealed class RecordingDenialReporter : IPrivilegedDenialReporter
{
public List<PrivilegedDenial> Reported { get; } = new();
public void Report(PrivilegedDenial denial)
{
lock (Reported) Reported.Add(denial);
}
}
@@ -0,0 +1,37 @@
using System.Net;
using AutopilotMonitor.Shared;
using AutopilotMonitor.Shared.Models;
using Microsoft.Azure.Functions.Worker;
using Microsoft.Azure.Functions.Worker.Http;
namespace AutopilotMonitor.Functions.Functions.Raw
{
/// <summary>
/// GET /api/global/raw/access-probe — a GlobalAdminOnly route that does nothing.
/// <para>
/// It exists for the deny path, not the allow path. When a caller without the GlobalAdmin role
/// asks the MCP for a GA-only tool, the tool is not registered for them and the SDK answers
/// "not found" before any backend call — so the backend, which owns the identity binding and the
/// <c>PrivilegedRouteDenied</c> ops event, would never learn about the probe. The MCP therefore
/// fires this route (fire-and-forget, <c>X-MCP-Tool-Name</c> = the attempted tool) and the policy
/// middleware refuses it exactly like any other GlobalAdminOnly route: same catalog, same identity
/// check, same throttled Critical event, no second writer and no caller-supplied event payload.
/// A genuine Global Admin reaching it simply gets a typed OK.
/// </para>
/// </summary>
public class AccessProbeFunction
{
[Function("GlobalAccessProbe")]
public async Task<HttpResponseData> Run(
[HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = "global/raw/access-probe")] HttpRequestData req,
FunctionContext context)
{
if (await RawGlobalAdminGate.DenyUnlessGlobalAdminAsync(req, context) is { } denied)
return denied;
var response = req.CreateResponse(HttpStatusCode.OK);
await response.WriteAsJsonAsync(new AccessProbeResponse { Success = true, Role = Constants.GlobalRoles.GlobalAdmin });
return response;
}
}
}
@@ -32,8 +32,14 @@ namespace AutopilotMonitor.Functions.Functions.Raw
/// </summary>
[Function("QueryBackendLogs")]
public async Task<HttpResponseData> Run(
[HttpTrigger(AuthorizationLevel.Anonymous, "post", Route = "global/raw/logs")] HttpRequestData req)
[HttpTrigger(AuthorizationLevel.Anonymous, "post", Route = "global/raw/logs")] HttpRequestData req,
FunctionContext context)
{
// Authorization is the catalog (GlobalAdminOnly) + PolicyEnforcementMiddleware; this is the
// in-body re-check so a middleware regression can never expose the KQL proxy.
if (await RawGlobalAdminGate.DenyUnlessGlobalAdminAsync(req, context) is { } denied)
return denied;
try
{
var appId = Environment.GetEnvironmentVariable("APPINSIGHTS_APP_ID");
@@ -0,0 +1,29 @@
using System.Net;
using AutopilotMonitor.Functions.Helpers;
using Microsoft.Azure.Functions.Worker;
using Microsoft.Azure.Functions.Worker.Http;
namespace AutopilotMonitor.Functions.Functions.Raw
{
/// <summary>
/// DEFENSE IN DEPTH for the raw table/log proxies. The catalog (<c>GlobalAdminOnly</c>) and the
/// policy middleware are the authorization gate; this re-check inside the function body only
/// guarantees that a regression there (middleware unregistered or reordered, a catalog edit to a
/// weaker tier) turns into a 403 instead of a secret-bearing dump. It reads the resolved
/// <see cref="RequestContext"/>: an empty context (middleware never ran) has
/// <c>IsGlobalAdmin == false</c>, so the gate fails closed by construction.
/// </summary>
internal static class RawGlobalAdminGate
{
/// <summary>Returns a 403 response to hand back, or null when the caller is a resolved Global Admin.</summary>
public static async Task<HttpResponseData?> DenyUnlessGlobalAdminAsync(HttpRequestData req, FunctionContext context)
{
if (context.GetRequestContext().IsGlobalAdmin)
return null;
var forbidden = req.CreateResponse(HttpStatusCode.Forbidden);
await forbidden.WriteAsJsonAsync(new { error = "Forbidden", message = "Global Admin role required." });
return forbidden;
}
}
}
@@ -39,8 +39,14 @@ namespace AutopilotMonitor.Functions.Functions.Raw
/// </summary>
[Function("ListRawTables")]
public async Task<HttpResponseData> ListTables(
[HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = "global/raw/tables")] HttpRequestData req)
[HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = "global/raw/tables")] HttpRequestData req,
FunctionContext context)
{
// Authorization is the catalog (GlobalAdminOnly) + PolicyEnforcementMiddleware; in-body re-check
// so a middleware regression can never expose the raw table surface.
if (await RawGlobalAdminGate.DenyUnlessGlobalAdminAsync(req, context) is { } denied)
return denied;
try
{
var tables = Constants.TableNames.All
@@ -65,8 +71,14 @@ namespace AutopilotMonitor.Functions.Functions.Raw
[Function("QueryRawTable")]
public async Task<HttpResponseData> QueryTable(
[HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = "global/raw/tables/{tableName}")] HttpRequestData req,
string tableName)
string tableName,
FunctionContext context)
{
// Authorization is the catalog (GlobalAdminOnly) + PolicyEnforcementMiddleware; in-body re-check
// so a middleware regression can never turn this into a secret-bearing table dump.
if (await RawGlobalAdminGate.DenyUnlessGlobalAdminAsync(req, context) is { } denied)
return denied;
try
{
// Validate table name
@@ -38,19 +38,25 @@ public class PolicyEnforcementMiddleware : IFunctionsWorkerMiddleware
private readonly DelegatedAdminService _delegatedAdminService;
private readonly TenantMemberRoleResolver _memberRoleResolver;
private readonly TenantConfigurationService _tenantConfigService;
private readonly IPrivilegedDenialReporter _denialReporter;
/// <summary>Longest header value that lands in a denial record; anything beyond is caller noise.</summary>
private const int MaxDenialHeaderLength = 64;
public PolicyEnforcementMiddleware(
ILogger<PolicyEnforcementMiddleware> logger,
GlobalAdminService globalAdminService,
DelegatedAdminService delegatedAdminService,
TenantMemberRoleResolver memberRoleResolver,
TenantConfigurationService tenantConfigService)
TenantConfigurationService tenantConfigService,
IPrivilegedDenialReporter denialReporter)
{
_logger = logger;
_globalAdminService = globalAdminService;
_delegatedAdminService = delegatedAdminService;
_memberRoleResolver = memberRoleResolver;
_tenantConfigService = tenantConfigService;
_denialReporter = denialReporter;
}
public async Task Invoke(FunctionContext context, FunctionExecutionDelegate next)
@@ -111,13 +117,79 @@ public class PolicyEnforcementMiddleware : IFunctionsWorkerMiddleware
return;
}
_logger.LogWarning("[PolicyEnforcement] DENIED {Method} {Path} status={Status} user={User} role={Role} reason={Reason}",
logMethod, logPath, result.StatusCode, LogSanitizer.Clean(result.UserIdentifier), result.UserRole, result.LogReason);
// Caller-supplied headers: sanitized + truncated before they reach a log line or a table row.
var clientSource = DenialHeader(httpContext, "X-Client-Source");
var mcpToolName = DenialHeader(httpContext, "X-MCP-Tool-Name");
_logger.LogWarning("[PolicyEnforcement] DENIED {Method} {Path} status={Status} user={User} role={Role} reason={Reason} oid={Oid} tid={Tid} clientSource={ClientSource} mcpTool={McpTool}",
logMethod, logPath, result.StatusCode, LogSanitizer.Clean(result.UserIdentifier), result.UserRole, result.LogReason,
LogSanitizer.Clean(principal?.GetObjectId()), LogSanitizer.Clean(principal?.GetTenantId()), clientSource, mcpToolName);
await ReportPrivilegedDenialAsync(context, result, httpMethod, requestPath, principal, clientSource, mcpToolName);
httpContext.Response.StatusCode = result.StatusCode;
httpContext.Response.ContentType = "application/json";
await httpContext.Response.WriteAsJsonAsync(new { error = result.ErrorCode, message = result.ErrorMessage });
}
private static string? DenialHeader(HttpContext httpContext, string name)
{
var raw = LogSanitizer.Clean(httpContext.Request.Headers[name].FirstOrDefault());
if (string.IsNullOrEmpty(raw))
return null;
return raw.Length <= MaxDenialHeaderLength ? raw : raw[..MaxDenialHeaderLength];
}
/// <summary>
/// ASSUME-BREACH layer for the Global-Admin-only surface. A 403 on a <c>GlobalAdminOnly</c> route by
/// an authenticated caller is the one denial that must never pass silently: it is either an operator
/// on the wrong account, a Global Reader overreaching, or someone probing the raw table/log proxies.
/// The event carries the backend's own view of the JWT (upn/oid/tid) plus the MCP tool name when the
/// call came through the MCP's access probe. 401s (no JWT / no upn) are not probes and stay log-only,
/// as do unregistered routes (anonymous scanner noise). The role lookup here is cached and only runs
/// on this rare path; it distinguishes a known Global Reader (Warning) from an outsider (Critical).
/// </summary>
private async Task ReportPrivilegedDenialAsync(
FunctionContext context, PolicyResult result, string httpMethod, string requestPath,
ClaimsPrincipal? principal, string? clientSource, string? mcpToolName)
{
if (!IsPrivilegedDenial(result) || principal == null)
return;
try
{
var globalRole = await _globalAdminService.GetGlobalRoleAsync(AdminIdentity.FromPrincipal(principal));
var denial = BuildPrivilegedDenial(result, httpMethod, requestPath, principal, globalRole,
clientSource, mcpToolName, context.GetCorrelationId());
_denialReporter.Report(denial);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "[PolicyEnforcement] privileged-denial reporting failed");
}
}
/// <summary>A 403 by an authenticated caller on a GlobalAdminOnly route — the only denial that is reported.</summary>
internal static bool IsPrivilegedDenial(PolicyResult result)
=> !result.Allowed
&& result.Policy == EndpointPolicy.GlobalAdminOnly
&& result.StatusCode == (int)HttpStatusCode.Forbidden;
/// <summary>Pure projection of a denial into the record the reporter writes; separated so tests can pin it without an HttpContext.</summary>
internal static PrivilegedDenial BuildPrivilegedDenial(
PolicyResult result, string httpMethod, string requestPath, ClaimsPrincipal principal, string? globalRole,
string? clientSource, string? mcpToolName, string? correlationId)
=> new(
Method: httpMethod,
Path: requestPath,
StatusCode: result.StatusCode,
Reason: result.LogReason,
Policy: result.Policy?.ToString() ?? string.Empty,
CallerId: principal.GetCallerId() ?? UnidentifiedCallerBucket,
Upn: principal.GetUserPrincipalName(),
ObjectId: principal.GetObjectId(),
TenantId: principal.GetTenantId(),
CallerRole: globalRole ?? "None",
ClientSource: clientSource,
McpToolName: mcpToolName,
CorrelationId: string.IsNullOrEmpty(correlationId) ? null : correlationId);
/// <summary>
/// Transport-agnostic authorization decision: catalog lookup → policy evaluation → cross-tenant
/// enforcement → RequestContext construction. Returns an allow (carrying the resolved RequestContext)
@@ -204,7 +276,7 @@ public class PolicyEnforcementMiddleware : IFunctionsWorkerMiddleware
var (code, message) = status == HttpStatusCode.Unauthorized
? ("AuthenticationRequired", "Authentication required. Please provide a valid JWT token.")
: ("InsufficientPermissions", "Access denied. You do not have permission to access this resource.");
return PolicyResult.Deny((int)status, code, message, decision.UserIdentifier, decision.UserRole, decision.Reason);
return PolicyResult.Deny((int)status, code, message, decision.UserIdentifier, decision.UserRole, decision.Reason, catalogEntry.Policy);
}
}
@@ -216,7 +288,7 @@ public class PolicyEnforcementMiddleware : IFunctionsWorkerMiddleware
LogSanitizer.Clean(decision.UserIdentifier), LogSanitizer.Clean(jwtTenantId), LogSanitizer.Clean(namedTarget), catalogEntry.TenantScoping, logPath);
return PolicyResult.Deny((int)HttpStatusCode.Forbidden, "CrossTenantAccessDenied",
"Access denied. You can only access your own tenant's resources.",
decision.UserIdentifier, decision.UserRole, "CrossTenant");
decision.UserIdentifier, decision.UserRole, "CrossTenant", catalogEntry.Policy);
}
// Tenant suspension gate — the operator kill-switch (TenantConfiguration.Disabled/DisabledUntil) and
@@ -238,7 +310,7 @@ public class PolicyEnforcementMiddleware : IFunctionsWorkerMiddleware
!string.IsNullOrEmpty(suspended.DisabledReason)
? suspended.DisabledReason
: "Your tenant has been suspended. Please contact support for more information.",
decision.UserIdentifier, decision.UserRole, "TenantSuspended");
decision.UserIdentifier, decision.UserRole, "TenantSuspended", catalogEntry.Policy);
}
}
@@ -656,12 +728,17 @@ public class PolicyEnforcementMiddleware : IFunctionsWorkerMiddleware
public string UserIdentifier { get; private init; } = "anonymous";
public string UserRole { get; private init; } = "N/A";
public string LogReason { get; private init; } = "";
/// <summary>
/// Policy tier of the denied catalog entry; null when the route is unregistered (fail-closed deny).
/// Lets <see cref="Invoke"/> single out GlobalAdminOnly denials for the assume-breach report.
/// </summary>
public EndpointPolicy? Policy { get; private init; }
public static PolicyResult Allow(RequestContext context, string user, string role)
=> new() { Allowed = true, Context = context, UserIdentifier = user, UserRole = role, LogReason = "Allow" };
public static PolicyResult Deny(int statusCode, string errorCode, string errorMessage,
string user, string role, string logReason)
string user, string role, string logReason, EndpointPolicy? policy = null)
=> new()
{
Allowed = false,
@@ -671,6 +748,7 @@ public class PolicyEnforcementMiddleware : IFunctionsWorkerMiddleware
UserIdentifier = user,
UserRole = role,
LogReason = logReason,
Policy = policy,
};
}
@@ -283,6 +283,8 @@ builder.Services.AddSingleton<
AutopilotMonitor.Functions.Services.Monitoring.AzurePoisonQueueProbe>();
builder.Services.AddSingleton<OpsAlertDispatchService>();
builder.Services.AddSingleton<OpsEventService>();
// Assume-breach observer on the policy middleware's deny path (GlobalAdminOnly 403 → Critical ops event).
builder.Services.AddSingleton<IPrivilegedDenialReporter, PrivilegedDenialReporter>();
// IME pattern-drift loop: folds ime_pattern_hits into ImePatternStats and raises
// ImePatternDriftSuspected; singleton because it caches the stats snapshot for evaluation.
builder.Services.AddSingleton<AutopilotMonitor.Functions.Services.Ime.ImePatternHealthService>();
@@ -422,6 +422,9 @@ public static class EndpointAccessPolicyCatalog
new("GET", "global/raw/tables", EndpointPolicy.GlobalAdminOnly),
new("GET", "global/raw/tables/{tableName}", EndpointPolicy.GlobalAdminOnly),
new("POST", "global/raw/logs", EndpointPolicy.GlobalAdminOnly),
// No-op GA-only route the MCP fires when a NON-GA caller attempts a GA-only tool: the deny
// path here (PolicyEnforcementMiddleware → PrivilegedRouteDenied) is the assume-breach alarm.
new("GET", "global/raw/access-probe", EndpointPolicy.GlobalAdminOnly),
new("GET", "global/search/sessions", EndpointPolicy.GlobalReadOrAdmin, TenantScoping.QueryParam),
new("GET", "global/search/sessions-by-event", EndpointPolicy.GlobalReadOrAdmin, TenantScoping.QueryParam),
new("GET", "global/search/sessions-by-cve", EndpointPolicy.GlobalReadOrAdmin, TenantScoping.QueryParam),
@@ -3,6 +3,7 @@ using System.Collections.Generic;
using System.Linq;
using System.Text.Json;
using System.Threading.Tasks;
using AutopilotMonitor.Shared;
using AutopilotMonitor.Shared.DataAccess;
using AutopilotMonitor.Shared.Models;
using Microsoft.Extensions.Logging;
@@ -321,6 +322,33 @@ namespace AutopilotMonitor.Functions.Services
tenantId, "System.SessionOwnerBinding",
new { sessionId, outcome, callerKind, ownerKind, serialMatch, agentVersion, endpoint, enforced = false });
/// <summary>
/// Assume-breach signal for the Global-Admin-only surface: an authenticated caller was refused
/// (403) on a <c>GlobalAdminOnly</c> route. Fired from the policy middleware's deny path via
/// <see cref="PrivilegedDenialReporter"/> (throttled 1h per caller+path, per instance). The MCP
/// routes a non-GA attempt at a GA-only tool through the same path (access-probe route), so
/// one event type covers both the direct-HTTP probe and the tool-catalog probe.
/// Critical unless the caller is a Global Reader (a known operator, not an intruder) — the
/// severity split lets one rule push Critical to Telegram while Reader slips stay on the page.
/// Recorded against the caller's HOME tenant (JWT tid); the identity is in the details.
/// </summary>
public Task RecordPrivilegedRouteDeniedAsync(PrivilegedDenial d)
{
var isReader = d.CallerRole == Constants.GlobalRoles.GlobalReader;
var severity = isReader ? OpsEventSeverity.Warning : OpsEventSeverity.Critical;
var via = d.ClientSource ?? "direct";
var tool = string.IsNullOrEmpty(d.McpToolName) ? string.Empty : $", tool {d.McpToolName}";
return WriteAsync(OpsEventCategory.Security, OpsEventTypes.PrivilegedRouteDenied, severity,
$"Privileged route denied: {d.Method} {d.Path} for {d.Upn ?? "(no upn)"} (role={d.CallerRole}, via {via}{tool})",
d.TenantId, d.Upn ?? "System.PolicyEnforcement",
new
{
method = d.Method, path = d.Path, statusCode = d.StatusCode, reason = d.Reason, policy = d.Policy,
upn = d.Upn, oid = d.ObjectId, tid = d.TenantId, callerRole = d.CallerRole,
clientSource = d.ClientSource, mcpToolName = d.McpToolName, correlationId = d.CorrelationId,
});
}
/// <summary>
/// Fired by <see cref="KillSwitchEvaluator"/> when a Kill signal was actually SERVED to
/// an agent (as opposed to DeviceBlocked/VersionBlocked, which fire when the admin
@@ -0,0 +1,88 @@
using System;
using System.Threading.Tasks;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging;
namespace AutopilotMonitor.Functions.Services
{
/// <summary>
/// One refused request on a <c>GlobalAdminOnly</c> route, as seen by the policy middleware.
/// Header-derived values (<see cref="ClientSource"/>, <see cref="McpToolName"/>) are caller-supplied
/// and already sanitized/truncated by the middleware before they land here.
/// </summary>
public sealed record PrivilegedDenial(
string Method,
string Path,
int StatusCode,
string Reason,
string Policy,
string CallerId,
string? Upn,
string? ObjectId,
string? TenantId,
string CallerRole,
string? ClientSource,
string? McpToolName,
string? CorrelationId);
/// <summary>
/// Seam between the policy middleware and the ops-event writer so the middleware stays unit-testable
/// without the notification stack (the test harness passes a recording fake).
/// </summary>
public interface IPrivilegedDenialReporter
{
/// <summary>Fire-and-forget; never throws, never blocks the deny response.</summary>
void Report(PrivilegedDenial denial);
}
/// <summary>
/// Assume-breach layer for the Global-Admin-only surface: turns a 403 on a <c>GlobalAdminOnly</c>
/// route into a <c>PrivilegedRouteDenied</c> ops event (Critical, or Warning for a Global Reader)
/// that operators route to a push channel. The authorization decision itself is not touched — this
/// only makes a refused probe visible with the backend's own view of the caller's identity.
/// </summary>
public sealed class PrivilegedDenialReporter : IPrivilegedDenialReporter
{
/// <summary>One event per (caller, path) per hour; the rest stays in the Warning trace.</summary>
internal static readonly TimeSpan Throttle = TimeSpan.FromHours(1);
private readonly OpsEventService _opsEvents;
private readonly IMemoryCache _cache;
private readonly ILogger<PrivilegedDenialReporter> _logger;
public PrivilegedDenialReporter(OpsEventService opsEvents, IMemoryCache cache, ILogger<PrivilegedDenialReporter> logger)
{
_opsEvents = opsEvents;
_cache = cache;
_logger = logger;
}
public void Report(PrivilegedDenial denial)
{
try
{
// The throttle is an in-process IMemoryCache, i.e. PER FUNCTION INSTANCE. On Flex
// Consumption a burst from one caller can fan out across instances, so the same
// (caller, path) can surface once per instance that saw it. Deliberately accepted —
// the count is a "how many instances noticed" figure, not an incident count, and a
// distributed throttle is not worth a storage round-trip on a deny path. Keyed on the
// path, not the tool name: a scripted sweep over every GA route still yields one
// event per route, while a repeat on the same route stays quiet for an hour.
var key = $"prd:{denial.CallerId}:{denial.Path}";
if (_cache.TryGetValue(key, out _))
return;
_cache.Set(key, true, Throttle);
_ = _opsEvents.RecordPrivilegedRouteDeniedAsync(denial)
.ContinueWith(
t => _logger.LogWarning(t.Exception?.InnerException, "PrivilegedRouteDenied ops event failed"),
TaskContinuationOptions.OnlyOnFaulted);
}
catch (Exception ex)
{
// Never let the observer break the deny response.
_logger.LogWarning(ex, "PrivilegedRouteDenied reporting failed");
}
}
}
}
@@ -33,7 +33,7 @@ function captureToolHandlers(ga: boolean): Record<string, Handler> {
function capturePromptNames(ga: boolean): string[] {
const names: string[] = [];
const fake = { registerPrompt: (name: string) => { names.push(name); } };
registerPrompts(fake as never, ga);
registerPrompts(fake as never, ga, ga);
return names;
}
@@ -0,0 +1,102 @@
/**
* The assume-breach probe: a caller WITHOUT the GlobalAdmin role naming a GA-only tool
* must (a) leave an unconditional security log line with identity and scope and (b) fire
* the backend access probe with the attempted tool name — while a Global Admin, a call to
* a non-GA tool, or a non-tool request must do neither. Pure unit level: the caller
* context is set via runWithCaller, fetch is stubbed.
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { runWithCaller } from '../client.js';
import { attemptedToolName, isGaToolProbe, observeGaToolProbe, ACCESS_PROBE_PATH } from '../ga-tool-probe.js';
import { GA_STRICT_TOOL_NAMES } from '../tools/admin.js';
const call = (name: string) => ({ jsonrpc: '2.0', id: 1, method: 'tools/call', params: { name, arguments: {} } });
let fetchMock: ReturnType<typeof vi.fn>;
let errorSpy: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
fetchMock = vi.fn(async () => new Response('{"error":"Forbidden"}', { status: 403 }));
vi.stubGlobal('fetch', fetchMock);
errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
});
afterEach(() => {
vi.unstubAllGlobals();
errorSpy.mockRestore();
});
async function flush(): Promise<void> {
await new Promise((r) => setTimeout(r, 0));
}
function securityLine(): string | undefined {
return errorSpy.mock.calls.map((c) => String(c[0])).find((l) => l.startsWith('[mcp-security] ga-tool-denied'));
}
describe('classification', () => {
it('extracts the tool name only from a tools/call body', () => {
expect(attemptedToolName(call('query_backend_logs'))).toBe('query_backend_logs');
expect(attemptedToolName({ method: 'tools/list' })).toBeUndefined();
expect(attemptedToolName({ method: 'tools/call', params: { name: 42 } })).toBeUndefined();
expect(attemptedToolName(undefined)).toBeUndefined();
});
it('flags every strictGa tool for a tenant caller and none for a Global Admin', () => {
for (const name of GA_STRICT_TOOL_NAMES) {
expect(runWithCaller({ token: 't', isGlobalAdmin: false }, () => isGaToolProbe(call(name)))).toBe(name);
expect(runWithCaller({ token: 't', isGlobalAdmin: true }, () => isGaToolProbe(call(name)))).toBeUndefined();
}
});
it('flags a Global Reader too (ga scope, but not strictGa)', () => {
expect(runWithCaller({ token: 't', isGlobalAdmin: false, isGlobalReader: true }, () => isGaToolProbe(call('query_table'))))
.toBe('query_table');
});
it('ignores non-GA tools and unknown names', () => {
expect(runWithCaller({ token: 't', isGlobalAdmin: false }, () => isGaToolProbe(call('get_session_summary')))).toBeUndefined();
expect(runWithCaller({ token: 't', isGlobalAdmin: false }, () => isGaToolProbe(call('no_such_tool')))).toBeUndefined();
});
});
describe('observeGaToolProbe', () => {
it('logs an unconditional security line and fires the access probe with the tool name', async () => {
runWithCaller({ token: 'tok', isGlobalAdmin: false, upn: 'user@contoso.com' }, () =>
observeGaToolProbe(call('query_backend_logs')));
await flush();
expect(securityLine()).toBe('[mcp-security] ga-tool-denied tool=query_backend_logs upn=user@contoso.com scope=tenant');
expect(fetchMock).toHaveBeenCalledTimes(1);
const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit];
expect(url.endsWith(ACCESS_PROBE_PATH)).toBe(true);
const headers = init.headers as Record<string, string>;
expect(headers['X-MCP-Tool-Name']).toBe('query_backend_logs');
expect(headers['Authorization']).toBe('Bearer tok');
expect(headers['X-Client-Source']).toBe('mcp');
});
it('reports the Reader scope so the log distinguishes an operator from an outsider', async () => {
runWithCaller({ token: 'tok', isGlobalAdmin: false, isGlobalReader: true, upn: 'reader@contoso.com' }, () =>
observeGaToolProbe(call('query_table')));
await flush();
expect(securityLine()).toContain('scope=ga');
expect(fetchMock).toHaveBeenCalledTimes(1);
});
it('is silent for a Global Admin and for non-GA tools', async () => {
runWithCaller({ token: 'tok', isGlobalAdmin: true }, () => observeGaToolProbe(call('query_backend_logs')));
runWithCaller({ token: 'tok', isGlobalAdmin: false }, () => observeGaToolProbe(call('get_session_summary')));
runWithCaller({ token: 'tok', isGlobalAdmin: false }, () => observeGaToolProbe({ method: 'tools/list' }));
await flush();
expect(fetchMock).not.toHaveBeenCalled();
expect(securityLine()).toBeUndefined();
});
it('never throws when the probe request fails', async () => {
fetchMock.mockImplementation(async () => { throw new Error('network down'); });
expect(() => runWithCaller({ token: 'tok', isGlobalAdmin: false }, () => observeGaToolProbe(call('list_tables')))).not.toThrow();
await flush();
});
});
@@ -16,7 +16,7 @@
* No backend token needed: validate_rule is MCP-local, and catalogs are built
* without search providers.
*/
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest';
import express from 'express';
import type { Server } from 'node:http';
import { Client } from '@modelcontextprotocol/client';
@@ -29,6 +29,8 @@ const DEPS: ServerDeps = { serverVersion: '0.0.0-test', knowledgeBase: undefined
/** Flipped per test: what accessGuard would have resolved for the caller. */
let callerIsGlobalAdmin = true;
/** Read-only Global Reader: platform scope without the strictGa surface. */
let callerIsGlobalReader = false;
let httpServer: Server;
let url: URL;
@@ -43,7 +45,7 @@ beforeAll(async () => {
// Mirrors index.ts: the guard scopes the caller via AsyncLocalStorage around
// the dispatch; the factory must observe it through the SDK entry.
app.post('/mcp', (req, res) =>
runWithCaller({ token: 'test-token', isGlobalAdmin: callerIsGlobalAdmin }, () => handler(req, res)),
runWithCaller({ token: 'test-token', isGlobalAdmin: callerIsGlobalAdmin, isGlobalReader: callerIsGlobalReader, upn: 'caller@contoso.com' }, () => handler(req, res)),
);
await new Promise<void>((resolve) => {
httpServer = app.listen(0, '127.0.0.1', () => resolve());
@@ -205,6 +207,85 @@ describe('role tailoring is identical across eras (privilege-leak guard)', () =>
expect(modern).toContain('list_tenants');
expect(modern).toContain('update_tenant_config');
});
it('a Global Reader sees the platform reads but none of the strictGa tools on either path', async () => {
callerIsGlobalAdmin = false;
callerIsGlobalReader = true;
try {
const modern = await toolNames('auto');
const legacy = await toolNames('legacy');
expect(modern).toEqual(legacy);
expect(modern).toContain('list_tenants');
expect(modern).toContain('get_ops_events');
for (const name of ['query_backend_logs', 'query_table', 'list_tables', 'update_tenant_config', 'get_tenant_config', 'annotate_session']) {
expect(modern, `${name} leaked to a Global Reader`).not.toContain(name);
}
const { client } = await connect('auto');
try {
// The debug prompt must not point a Reader at tools it cannot see.
const prompt = await client.getPrompt({ name: 'debug-session', arguments: { sessionId: 'e259c121-1234-4abc-9def-0123456789ab' } });
const text = prompt.messages.map((m) => (m.content as { text?: string }).text ?? '').join(' ');
expect(text).not.toContain('query_backend_logs');
} finally {
await client.close();
}
} finally {
callerIsGlobalAdmin = true;
callerIsGlobalReader = false;
}
});
it('a Global Admin is pointed at the raw tools by the debug prompt', async () => {
callerIsGlobalAdmin = true;
const { client } = await connect('auto');
try {
const prompt = await client.getPrompt({ name: 'debug-session', arguments: { sessionId: 'e259c121-1234-4abc-9def-0123456789ab' } });
const text = prompt.messages.map((m) => (m.content as { text?: string }).text ?? '').join(' ');
expect(text).toContain('query_backend_logs');
} finally {
await client.close();
}
});
});
describe('assume-breach: a non-GA call to a GA-only tool', () => {
it('is refused as not-found AND fires the backend access probe with the tool name', async () => {
callerIsGlobalAdmin = false;
const realFetch = globalThis.fetch;
// The MCP client transport and the server-side probe share globalThis.fetch: pass the client's
// own POSTs to the loopback server through untouched and answer only the backend probe.
const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
if (String(input).endsWith('/api/global/raw/access-probe')) {
return new Response('{"error":"Forbidden"}', { status: 403 });
}
return realFetch(input, init);
});
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
try {
const { client } = await connect('auto');
globalThis.fetch = fetchMock as unknown as typeof fetch;
try {
// The SDK refuses before any handler: the 2026 client surfaces that as a thrown protocol error.
await expect(client.callTool({ name: 'query_backend_logs', arguments: { query: 'traces | take 1' } }))
.rejects.toThrow(/query_backend_logs not found/);
} finally {
globalThis.fetch = realFetch;
await client.close();
}
await new Promise((r) => setTimeout(r, 20));
const probe = fetchMock.mock.calls.find(([u]) => String(u).endsWith('/api/global/raw/access-probe'));
expect(probe, 'access probe was not fired').toBeDefined();
const headers = (probe![1] as RequestInit).headers as Record<string, string>;
expect(headers['X-MCP-Tool-Name']).toBe('query_backend_logs');
expect(headers['Authorization']).toBe('Bearer test-token');
const line = errorSpy.mock.calls.map((c) => String(c[0])).find((l) => l.startsWith('[mcp-security] ga-tool-denied'));
expect(line).toContain('tool=query_backend_logs');
expect(line).toContain('upn=caller@contoso.com');
} finally {
errorSpy.mockRestore();
callerIsGlobalAdmin = true;
}
});
});
describe('transport hygiene', () => {
@@ -20,6 +20,7 @@
import { describe, it, expect } from 'vitest';
import { McpServer } from '@modelcontextprotocol/server';
import { registerTools } from '../tools.js';
import { GA_STRICT_TOOL_NAMES } from '../tools/admin.js';
import type { SearchProvider } from '../search-provider.js';
function registeredToolNames(ga: boolean, strictGa: boolean = ga, delegated: boolean = false): string[] {
@@ -264,6 +265,17 @@ describe('role catalog snapshot — privilege-leak guard', () => {
}
});
// The static set the assume-breach probe (ga-tool-probe.ts) classifies against must equal the
// REGISTERED strictGa difference — a new `if (strictGa)` site that forgets the set would leave
// that tool's probes unreported; a stale entry would report a tool Readers legitimately see.
it('GA_STRICT_TOOL_NAMES equals the registered strictGa difference (GA minus Global Reader)', () => {
const readerNames = registeredToolNames(true, false);
const registeredDiff = registeredToolNames(true, true).filter((n) => !readerNames.includes(n)).sort();
expect([...GA_STRICT_TOOL_NAMES].sort()).toEqual(registeredDiff);
expect([...GA_STRICT_TOOL_NAMES].sort()).toEqual(
[...RAW_GA_STRICT, ...CONFIG_WRITE_GA_STRICT, ...ANNOTATION_WRITE_GA_STRICT].sort());
});
it('the secret-bearing raw tools are a subset of the platform-only tools', () => {
// A raw GA-strict tool is by definition also platform-only; this documents
// (and enforces) that the Reader split never re-exposes a tenant-hidden tool.
@@ -167,6 +167,11 @@ export function getHomeTenantId(): string | undefined {
* when no context is active / no UPN was captured. Display label for the synthesized home-tenant entry
* in list_tenants — see CallerContext.upn.
*/
/** The current caller's UPN (lowercase) as the access check recorded it, or undefined without a context. */
export function getCallerUpn(): string | undefined {
return callerStore.getStore()?.upn;
}
export function getCallerUpnDomain(): string | undefined {
const upn = callerStore.getStore()?.upn ?? '';
const at = upn.indexOf('@');
@@ -0,0 +1,59 @@
/**
* ASSUME-BREACH observer for the Global-Admin-only tool surface.
*
* The GA-only tools are simply not registered for a caller without the GlobalAdmin
* role, so a `tools/call` naming one of them is refused by the SDK before any handler
* runs ("Tool X not found") and no backend request is ever made. That is correct as a
* boundary — and blind as a signal: the backend, which owns the identity binding and
* the `PrivilegedRouteDenied` ops event (Critical → push), never learns that someone
* without the role asked for `query_backend_logs`.
*
* This module closes that gap without adding a second alarm writer:
* 1. an UNCONDITIONAL security log line (not gated on MCP_TOOL_LOGGING, same style as
* the `[mcp-auth]` 401/403 lines in access-guard.ts) naming tool, upn and scope;
* 2. a fire-and-forget GET on the backend's GlobalAdminOnly no-op route
* `/api/global/raw/access-probe`, with `X-MCP-Tool-Name` = the attempted tool. The
* backend's policy middleware refuses it exactly like any other GA route and its
* deny path raises the throttled ops event with the backend's own view of the JWT.
*
* The SDK's answer to the client is unchanged (-32602 Tool not found). A caller who IS
* a Global Admin never reaches this: the tool is registered and dispatches normally.
*/
import { apiFetch, isGlobalAdmin, getCallerUpn } from './client.js';
import { callerScope } from './telemetry.js';
import { GA_STRICT_TOOL_NAMES } from './tools/admin.js';
/** Backend route whose DENIAL is the alarm. Registered GlobalAdminOnly; a GA gets a typed OK. */
export const ACCESS_PROBE_PATH = '/api/global/raw/access-probe';
/** The tool name of a `tools/call` body, or undefined for any other request shape. */
export function attemptedToolName(body: unknown): string | undefined {
const b = body as { method?: unknown; params?: { name?: unknown } } | undefined;
if (b?.method !== 'tools/call') return undefined;
return typeof b.params?.name === 'string' ? b.params.name : undefined;
}
/** Pure classification: is this request a non-GA caller asking for a GA-only tool? */
export function isGaToolProbe(body: unknown): string | undefined {
const name = attemptedToolName(body);
if (!name || !GA_STRICT_TOOL_NAMES.has(name) || isGlobalAdmin()) return undefined;
return name;
}
/**
* Observe one POST /mcp body. Must run inside the caller's async context (runWithCaller)
* and BEFORE dispatch. Never throws and never blocks the response: the probe request is
* detached, its outcome irrelevant here (the backend records it, or it fails silently).
*/
export function observeGaToolProbe(body: unknown): void {
try {
const tool = isGaToolProbe(body);
if (!tool) return;
console.error(`[mcp-security] ga-tool-denied tool=${tool} upn=${getCallerUpn() ?? '?'} scope=${callerScope()}`);
void apiFetch(ACCESS_PROBE_PATH, { headers: { 'X-MCP-Tool-Name': tool } }).catch(() => {
// Expected: the backend answers 403 (that IS the signal). Nothing to do here.
});
} catch {
// Observing must never break the request path.
}
}
@@ -14,6 +14,13 @@ export interface AcceptDelegationInvitationResponse {
managedTenantId: string;
}
/** Success body of GET /api/global/raw/access-probe (GlobalAccessProbe): the no-op GlobalAdminOnly route the MCP fires when a non-GA caller attempts a GA-only tool, so the backend's deny path records the probe. A Global Admin gets this body; everyone else gets the middleware's 403. */
export interface AccessProbeResponse {
success: boolean;
/** Always "GlobalAdmin" — the only role that reaches the body. */
role: string;
}
/** One currently active web user as listed by GET global/presence. */
export interface ActiveUserItem {
tenantId: string;
@@ -100,6 +100,7 @@ export const OPS_EVENT_TYPES = [
"PoisonQueueBacklogHigh",
"PoisonQueueBacklogCritical",
"ExcessiveSessionEventsAutoActioned",
"PrivilegedRouteDenied",
"OffboardingFeedbackReceived",
"TenantOffboarded",
"TenantOffboardingFailed",
@@ -29,6 +29,7 @@ import type { Request, Response } from 'express';
import { createMcpHandler, isLegacyRequest } from '@modelcontextprotocol/server';
import type { McpServer, McpRequestContext } from '@modelcontextprotocol/server';
import { NodeStreamableHTTPServerTransport, toNodeHandler, toWebRequest } from '@modelcontextprotocol/node';
import { observeGaToolProbe } from './ga-tool-probe.js';
/** Builds the per-request server. Runs inside the caller's async context (see access-guard runWithCaller). */
export type PerRequestServerFactory = (ctx: McpRequestContext) => McpServer;
@@ -67,6 +68,11 @@ export function createMcpRequestHandler(
const modernNode = toNodeHandler(modern, { onerror });
return async (req, res) => {
// Assume-breach signal BEFORE dispatch: a non-GA caller naming a GA-only tool is refused by
// the SDK as "not found" and would otherwise leave no trace with an identity. Runs inside the
// caller's async context (both eras share this entry). Never throws, never alters the response.
observeGaToolProbe(req.body);
// Classification needs the method + headers of the request and the
// (already parsed) body. Passing `parsedBody` means nothing is read from
// the Node stream — express.json() already drained it.
@@ -120,8 +120,10 @@ export function createMcpServer(deps: ServerDeps, ga: boolean, strictGa: boolean
registerTools(s, deps.knowledgeBase, deps.eventTypeIndex, deps.docs, ga, strictGa, delegated);
registerResources(s);
// A delegated caller has no platform scope, so prompts get the tenant-user surface (ga=false) —
// the cross-tenant prompt wording would be misleading for a tenant-bounded MSP user.
registerPrompts(s, ga);
// the cross-tenant prompt wording would be misleading for a tenant-bounded MSP user. strictGa
// gates the prompt steps that name the strictGa-only raw tools (a Reader must not be told to
// call tools it cannot see).
registerPrompts(s, ga, strictGa);
return s;
}
@@ -14,7 +14,7 @@ import { z } from 'zod';
* with Zod but keep types as strings; numeric/optional semantics are described
* in the argument text for the model.
*/
export function registerPrompts(server: McpServer, ga: boolean): void {
export function registerPrompts(server: McpServer, ga: boolean, strictGa: boolean): void {
server.registerPrompt(
'investigate-failed-session',
{
@@ -89,7 +89,9 @@ export function registerPrompts(server: McpServer, ga: boolean): void {
'Use get_session_events / query_raw_events for the raw stream around the first error; gaps ' +
'between agent log and Events reveal upload/network issues. Use search_knowledge to look up ' +
'rules / IME patterns / error codes you encounter.\n' +
(ga
// strictGa, not ga: query_backend_logs / query_table are registered for a real Global
// Admin only — a Global Reader must not be pointed at tools it cannot see.
(strictGa
? '4. If the problem looks like a backend ingest/upload issue (events the agent logged as sent ' +
'are missing, or status disagrees with events), use query_backend_logs (App Insights KQL) and ' +
'query_table (e.g. RuleResults, AppInstallSummaries) to trace it.\n'
@@ -1,4 +1,4 @@
import { runWithToolName, hasGlobalScope, isDelegated } from './client.js';
import { runWithToolName, hasGlobalScope, isDelegated, getCallerUpn } from './client.js';
export const toolLoggingEnabled = process.env.MCP_TOOL_LOGGING === 'true';
@@ -63,7 +63,7 @@ export function summarizeArgs(
}
/** Caller scope for usage analysis — which audience a tool struggle belongs to. */
function callerScope(): 'ga' | 'delegated' | 'tenant' {
export function callerScope(): 'ga' | 'delegated' | 'tenant' {
if (hasGlobalScope()) return 'ga';
if (isDelegated()) return 'delegated';
return 'tenant';
@@ -175,6 +175,11 @@ export function logToolCallRejection(toolName: string, errorCode: number, messag
tool: toolName,
errorCode,
message: cap(message, 500),
// Who was refused: without scope + upn a "tool not found" from a caller probing a
// GA-only tool is indistinguishable from a typo (the security line in ga-tool-probe.ts
// is the unconditional counterpart; this one is the usage-analysis view).
scope: callerScope(),
upn: getCallerUpn(),
timestamp: new Date().toISOString(),
}));
} catch {
@@ -6,6 +6,25 @@ import { getResourceContent, assertKnownEventType, RESOURCE_NAMES } from '../res
import { READ_ONLY, READ_ONLY_OPEN, MUTATING, MAX_RESULT_SIZE_CHARS, LEAN_RAW_EVENT_FIELDS, LEAN_RAW_EVENT_OMISSION, toolResultText, SessionIdSchema, TenantGuidSchema, tenantIdDescription } from './shared.js';
import { toolError } from './error-handler.js';
/**
* The tools registered ONLY for a real Global Admin (`strictGa`), by name. Static on purpose: the
* per-request registration below only runs for callers who pass the gate, so a non-GA request could
* never discover the set at runtime — yet that request is exactly the one the assume-breach probe
* (ga-tool-probe.ts) must classify. tools-catalog-order.test.ts pins this set against the registered
* catalog difference (GA minus Global Reader), so a new `if (strictGa)` site cannot drift from it.
*/
export const GA_STRICT_TOOL_NAMES: ReadonlySet<string> = new Set([
'annotate_session',
'list_tables',
'query_table',
'query_backend_logs',
'get_tenant_config',
'get_tenant_config_schema',
'update_tenant_config',
'list_tenant_config_backups',
'revert_tenant_config',
]);
/** Default first-page size of get_fleet_overview's session list (a fleet snapshot, not a sweep). */
const FLEET_OVERVIEW_PAGE_SIZE = 25;
@@ -1438,10 +1457,14 @@ export function registerAdminTools(server: McpServer, ga: boolean, strictGa: boo
);
// ── Tenant-config write surface (transactional) ───────────────────────
// Security note: all four tools are strictGa (real Global Admin only) — the backend
// routes (PATCH config/{tenantId}/fields, GET .../backups, POST .../revert) are
// GlobalAdminOnly to match. get_tenant_config is read-only but ALSO strictGa: its
// full-config view (even redacted) exposes operational settings no Reader needs.
// Security note: all five tools are strictGa (real Global Admin only). Backend backing per
// route (EndpointAccessPolicyCatalog): GET .../backups and POST .../revert are GlobalAdminOnly;
// PATCH config/{tenantId}/fields is TenantAdminOrGA and GET config/{tenantId} is MemberRead
// (secrets redacted for non-admins) — for those two the strictGa registration is the MCP-side
// decision to keep the operator config surface off the Reader/tenant catalog, NOT a claim that
// the backend would refuse a tenant admin on the same URL with their own token.
// get_tenant_config is read-only but ALSO strictGa: its full-config view (even redacted)
// exposes operational settings no Reader needs.
// The write flow is transactional server-side: fail-closed pre-write snapshot →
// ETag-conditional write → re-read → verify that EXACTLY the intended fields
// changed → automatic rollback on drift. Every change is revertible.
@@ -70,6 +70,12 @@ namespace AutopilotMonitor.Shared.DataAccess
public const string PoisonQueueBacklogHigh = "PoisonQueueBacklogHigh";
public const string PoisonQueueBacklogCritical = "PoisonQueueBacklogCritical";
public const string ExcessiveSessionEventsAutoActioned = "ExcessiveSessionEventsAutoActioned";
/// <summary>
/// An authenticated caller without the GlobalAdmin role was refused (403) on a
/// <c>GlobalAdminOnly</c> route — including the MCP probe a non-GA tool call triggers.
/// Critical for callers without any platform role, Warning for a Global Reader.
/// </summary>
public const string PrivilegedRouteDenied = "PrivilegedRouteDenied";
// ── Tenant ── Tenant lifecycle: offboarding, trials, plan changes, regression radars, config toggles.
public const string OffboardingFeedbackReceived = "OffboardingFeedbackReceived";
@@ -116,7 +122,7 @@ namespace AutopilotMonitor.Shared.DataAccess
{
ConsentFlowStarted, ConsentFlowSuccess, ConsentFlowFailed, ConsentRedirectUriMismatch, AppHomingFlipped, AppHomingFlippedWithEntraRoles,
MaintenanceCompleted, MaintenanceFailed, MaintenanceLongRunning, SessionSweepCompleted, SessionSweepFailed, OpsEventCleanup, OrphanEventsCleaned, SessionDeletionMaintenanceStarted, SessionDeletionMaintenanceBudgetExceeded, SessionDeletionMaintenanceSkippedLocked, SessionDeletionMaintenanceLongRunning, SessionDeletionMaintenanceLongRunningSevere, SessionDeletionMaintenanceFailed, SessionDeletionStrandedQueued, SessionDeletionPoisoned, SessionDeletionMaintenanceCompleted, SessionDeletionMaintenanceFanoutSkipped, CriticalTableBackupCompleted, CriticalTableBackupPartial, CriticalTableBackupFailed, CriticalTableBackupSkippedLocked, BackupRowRestored, VerdictCalibrationDrift,
DeviceBlocked, VersionBlocked, SessionTenantConflict, SessionOwnerMismatch, KillSignalDelivered, EmbeddedCertExpiringSoon, EmbeddedCertExpiringUrgent, EmbeddedCertExpired, EmbeddedCertBundleEmpty, SignalRConnectionsHigh, SignalRConnectionsCritical, SignalRMessagesHigh, SignalRMessagesCritical, PoisonQueueBacklogHigh, PoisonQueueBacklogCritical, ExcessiveSessionEventsAutoActioned,
DeviceBlocked, VersionBlocked, SessionTenantConflict, SessionOwnerMismatch, KillSignalDelivered, EmbeddedCertExpiringSoon, EmbeddedCertExpiringUrgent, EmbeddedCertExpired, EmbeddedCertBundleEmpty, SignalRConnectionsHigh, SignalRConnectionsCritical, SignalRMessagesHigh, SignalRMessagesCritical, PoisonQueueBacklogHigh, PoisonQueueBacklogCritical, ExcessiveSessionEventsAutoActioned, PrivilegedRouteDenied,
OffboardingFeedbackReceived, TenantOffboarded, TenantOffboardingFailed, TenantAutoApproved, WelcomeEmailSent, WelcomeEmailSkipped, WelcomeEmailFailed, TenantTrialStarted, TenantTrialExpiring, TenantTrialExpired, TenantPlanDowngraded, TenantRetentionGraceExpiring, TenantRetentionGraceEnded, RuleFrequencyRegression, AppVersionDurationRegression, CollectLogsQuickConfigEnabled, DiagnosticsUploadEnabled, DiagnosticsUploadDisabled,
SessionActionQueued, SessionTimeouts, AgentEmergencyBreak, AgentBinaryIntegrityMismatch, CmTraceTimeSkewRegression, ExcessiveSessionEvents, NewImeVersionDetected, ImePatternDriftSuspected, BlobStorageMissing, BlobStorageUnreachable,
SlaBreachNotification, SlaConsecutiveFailures, SlaEvaluationCompleted,
@@ -56,6 +56,19 @@ namespace AutopilotMonitor.Shared.Models
public IReadOnlyList<string> Tables { get; set; } = default!;
}
// Declaration order == wire order.
/// <summary>
/// Success body of GET /api/global/raw/access-probe (GlobalAccessProbe): the no-op GlobalAdminOnly
/// route the MCP fires when a non-GA caller attempts a GA-only tool, so the backend's deny path
/// records the probe. A Global Admin gets this body; everyone else gets the middleware's 403.
/// </summary>
public class AccessProbeResponse : IApiResponse
{
public bool Success { get; set; }
/// <summary>Always "GlobalAdmin" — the only role that reaches the body.</summary>
public string Role { get; set; } = default!;
}
// Declaration order == wire order.
/// <summary>
/// Success body of GET /api/global/raw/tables/{tableName} (QueryRawTable): raw table rows,
@@ -97,6 +97,11 @@ const OPS_EVENT_TYPES: Record<string, string[]> = {
// per memory feedback_ops_event_types_dual_register so operators can wire a
// dedicated Telegram rule independent of the warn-tier ExcessiveSessionEvents.
"ExcessiveSessionEventsAutoActioned",
// Assume-breach layer for the Global-Admin-only surface: an authenticated caller without the
// GlobalAdmin role hit a GlobalAdminOnly route (or, via the MCP probe, a GA-only tool).
// Critical for callers without any platform role, Warning for a Global Reader; throttled 1h
// per caller+path (PrivilegedDenialReporter). Route it to Telegram at MinSeverity Critical.
"PrivilegedRouteDenied",
"VersionBlocked",
// Delivery confirmation: a Kill signal was actually SERVED to an agent (config or
// telemetry channel) — as opposed to DeviceBlocked/VersionBlocked which fire on rule
@@ -275,6 +275,7 @@ export const SHARED_MANIFEST = {
"PoisonQueueBacklogHigh",
"PoisonQueueBacklogCritical",
"ExcessiveSessionEventsAutoActioned",
"PrivilegedRouteDenied",
"OffboardingFeedbackReceived",
"TenantOffboarded",
"TenantOffboardingFailed",
@@ -271,6 +271,7 @@
"PoisonQueueBacklogHigh",
"PoisonQueueBacklogCritical",
"ExcessiveSessionEventsAutoActioned",
"PrivilegedRouteDenied",
"OffboardingFeedbackReceived",
"TenantOffboarded",
"TenantOffboardingFailed",
@@ -577,6 +578,27 @@
}
]
},
"AccessProbeResponse": {
"kind": "object",
"doc": "Success body of GET /api/global/raw/access-probe (GlobalAccessProbe): the no-op GlobalAdminOnly route the MCP fires when a non-GA caller attempts a GA-only tool, so the backend's deny path records the probe. A Global Admin gets this body; everyone else gets the middleware's 403.",
"fields": [
{
"name": "success",
"type": {
"kind": "primitive",
"name": "boolean"
}
},
{
"name": "role",
"type": {
"kind": "primitive",
"name": "string"
},
"doc": "Always \"GlobalAdmin\" — the only role that reaches the body."
}
]
},
"ActiveUserItem": {
"kind": "object",
"doc": "One currently active web user as listed by GET global/presence.",
@@ -14,6 +14,13 @@ export interface AcceptDelegationInvitationResponse {
managedTenantId: string;
}
/** Success body of GET /api/global/raw/access-probe (GlobalAccessProbe): the no-op GlobalAdminOnly route the MCP fires when a non-GA caller attempts a GA-only tool, so the backend's deny path records the probe. A Global Admin gets this body; everyone else gets the middleware's 403. */
export interface AccessProbeResponse {
success: boolean;
/** Always "GlobalAdmin" — the only role that reaches the body. */
role: string;
}
/** One currently active web user as listed by GET global/presence. */
export interface ActiveUserItem {
tenantId: string;