From 729ee56bdee6600ea832d5fc86665d110e6d290d Mon Sep 17 00:00:00 2001 From: Oliver Kieselbach Date: Fri, 4 Sep 2026 13:29:06 +0200 Subject: [PATCH] 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 --- internal | 2 +- .../AdminIdentityBindingAuthorizationTests.cs | 3 +- .../AppInsightsQueryFunctionTests.cs | 10 ++ .../MiddlewarePipelineOrderTests.cs | 60 +++++++ .../PolicyEnforcementMiddlewareTests.cs | 120 +++++++++++++- .../PrivilegedDenialReporterTests.cs | 147 ++++++++++++++++++ .../RawGlobalAdminGateTests.cs | 83 ++++++++++ .../RawWireParityTests.cs | 12 ++ .../TestDenialReporter.cs | 19 +++ .../Functions/Raw/AccessProbeFunction.cs | 37 +++++ .../Functions/Raw/AppInsightsQueryFunction.cs | 8 +- .../Functions/Raw/RawGlobalAdminGate.cs | 29 ++++ .../Functions/Raw/TableQueryFunction.cs | 16 +- .../Middleware/PolicyEnforcementMiddleware.cs | 92 ++++++++++- .../AutopilotMonitor.Functions/Program.cs | 2 + .../Security/EndpointAccessPolicyCatalog.cs | 3 + .../Services/OpsEventService.cs | 28 ++++ .../Services/PrivilegedDenialReporter.cs | 88 +++++++++++ .../src/__tests__/diag-session.test.ts | 2 +- .../src/__tests__/ga-tool-probe.test.ts | 102 ++++++++++++ .../src/__tests__/mcp-http.test.ts | 85 +++++++++- .../src/__tests__/tools-catalog-order.test.ts | 12 ++ .../autopilot-monitor-mcp/src/client.ts | 5 + .../src/ga-tool-probe.ts | 59 +++++++ .../src/generated/wire-types.generated.ts | 7 + .../generated/wire-vocabularies.generated.ts | 1 + .../autopilot-monitor-mcp/src/mcp-http.ts | 6 + .../src/mcp-server-factory.ts | 6 +- .../autopilot-monitor-mcp/src/prompts.ts | 6 +- .../autopilot-monitor-mcp/src/telemetry.ts | 9 +- .../autopilot-monitor-mcp/src/tools/admin.ts | 31 +++- .../DataAccess/OpsEventTypes.cs | 8 +- .../Models/RawApiModels.cs | 13 ++ .../admin/components/OpsAlertRulesSection.tsx | 5 + .../utils/shared-manifests.generated.ts | 1 + .../utils/shared-manifests.json | 22 +++ .../utils/wire-types.generated.ts | 7 + 37 files changed, 1119 insertions(+), 27 deletions(-) create mode 100644 src/Backend/AutopilotMonitor.Functions.Tests/MiddlewarePipelineOrderTests.cs create mode 100644 src/Backend/AutopilotMonitor.Functions.Tests/PrivilegedDenialReporterTests.cs create mode 100644 src/Backend/AutopilotMonitor.Functions.Tests/RawGlobalAdminGateTests.cs create mode 100644 src/Backend/AutopilotMonitor.Functions.Tests/TestDenialReporter.cs create mode 100644 src/Backend/AutopilotMonitor.Functions/Functions/Raw/AccessProbeFunction.cs create mode 100644 src/Backend/AutopilotMonitor.Functions/Functions/Raw/RawGlobalAdminGate.cs create mode 100644 src/Backend/AutopilotMonitor.Functions/Services/PrivilegedDenialReporter.cs create mode 100644 src/McpServer/autopilot-monitor-mcp/src/__tests__/ga-tool-probe.test.ts create mode 100644 src/McpServer/autopilot-monitor-mcp/src/ga-tool-probe.ts diff --git a/internal b/internal index 0b5da6ce..621240b3 160000 --- a/internal +++ b/internal @@ -1 +1 @@ -Subproject commit 0b5da6ce1e0a11b7d3a3c9b2584697ddc250abb6 +Subproject commit 621240b3aabcdd73e03d3c9e3fa3b257c3c09524 diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/AdminIdentityBindingAuthorizationTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/AdminIdentityBindingAuthorizationTests.cs index 0d4a480b..62934124 100644 --- a/src/Backend/AutopilotMonitor.Functions.Tests/AdminIdentityBindingAuthorizationTests.cs +++ b/src/Backend/AutopilotMonitor.Functions.Tests/AdminIdentityBindingAuthorizationTests.cs @@ -80,7 +80,8 @@ public class AdminIdentityBindingAuthorizationTests { Middleware = new PolicyEnforcementMiddleware( NullLogger.Instance, globalAdmin, delegatedAdmin, - new TenantMemberRoleResolver(tenantAdmins, tenantConfig), tenantConfig), + new TenantMemberRoleResolver(tenantAdmins, tenantConfig), tenantConfig, + new RecordingDenialReporter()), Repo = repo, }; } diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/AppInsightsQueryFunctionTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/AppInsightsQueryFunctionTests.cs index 95a9261f..78c595ae 100644 --- a/src/Backend/AutopilotMonitor.Functions.Tests/AppInsightsQueryFunctionTests.cs +++ b/src/Backend/AutopilotMonitor.Functions.Tests/AppInsightsQueryFunctionTests.cs @@ -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). ── diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/MiddlewarePipelineOrderTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/MiddlewarePipelineOrderTests.cs new file mode 100644 index 00000000..f02f9caf --- /dev/null +++ b/src/Backend/AutopilotMonitor.Functions.Tests/MiddlewarePipelineOrderTests.cs @@ -0,0 +1,60 @@ +using System.IO; +using AutopilotMonitor.Functions.Middleware; +using Xunit; + +namespace AutopilotMonitor.Functions.Tests; + +/// +/// The catalog and are the authorization gate for every +/// HTTP route, and the raw table/log proxies rely on the RequestContext it resolves. Nothing +/// at runtime would notice if the registration in Program.cs were dropped or moved in front of +/// (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. +/// +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; + } +} diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/PolicyEnforcementMiddlewareTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/PolicyEnforcementMiddlewareTests.cs index 001605ab..dfcbe30e 100644 --- a/src/Backend/AutopilotMonitor.Functions.Tests/PolicyEnforcementMiddlewareTests.cs +++ b/src/Backend/AutopilotMonitor.Functions.Tests/PolicyEnforcementMiddlewareTests.cs @@ -119,11 +119,129 @@ public class PolicyEnforcementMiddlewareTests var mw = new PolicyEnforcementMiddleware( NullLogger.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")); diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/PrivilegedDenialReporterTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/PrivilegedDenialReporterTests.cs new file mode 100644 index 00000000..5dbaf21b --- /dev/null +++ b/src/Backend/AutopilotMonitor.Functions.Tests/PrivilegedDenialReporterTests.cs @@ -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; + +/// +/// The assume-breach alarm for the Global-Admin-only surface: a refused GlobalAdminOnly request must +/// land as a PrivilegedRouteDenied 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. +/// +public class PrivilegedDenialReporterTests +{ + private static (PrivilegedDenialReporter Reporter, List Saved) Rig(bool repoThrows = false) + { + var saved = new List(); + var opsRepo = new Mock(); + var setup = opsRepo.Setup(r => r.SaveOpsEventAsync(It.IsAny())); + if (repoThrows) + setup.ThrowsAsync(new System.InvalidOperationException("table down")); + else + setup.Callback(e => { lock (saved) saved.Add(e); }).Returns(Task.CompletedTask); + var adminConfig = new Mock( + Mock.Of(), NullLogger.Instance, + new MemoryCache(new MemoryCacheOptions())) { CallBase = false }; + var opsEvents = new OpsEventService(opsRepo.Object, NullLogger.Instance, + TestNotifications.InertOpsAlertDispatch(adminConfig.Object)); + var reporter = new PrivilegedDenialReporter(opsEvents, new MemoryCache(new MemoryCacheOptions()), + NullLogger.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"); + + /// The write is fire-and-forget; give the completed-task continuation a moment. + private static List Settle(List 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); + } +} diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/RawGlobalAdminGateTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/RawGlobalAdminGateTests.cs new file mode 100644 index 00000000..9b78c072 --- /dev/null +++ b/src/Backend/AutopilotMonitor.Functions.Tests/RawGlobalAdminGateTests.cs @@ -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; + +/// +/// 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 ) or resolved a non-GA caller, and stays silent for +/// a resolved Global Admin. +/// +public class RawGlobalAdminGateTests +{ + private static (HttpRequestData Req, FunctionContext Ctx) BuildRequest(RequestContext? requestContext) + { + var services = new ServiceCollection(); + services.AddOptions(); + services.Configure(o => o.Serializer = new JsonObjectSerializer(ApiJsonOptions.Create())); + var provider = services.BuildServiceProvider(); + + var items = new Dictionary(); + if (requestContext != null) + items[RequestContext.ItemsKey] = requestContext; + + var context = new Mock(); + context.SetupGet(c => c.Items).Returns(items); + context.SetupGet(c => c.InstanceServices).Returns(provider); + + var req = new Mock(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().Object; + } +} diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/RawWireParityTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/RawWireParityTests.cs index 0a18a9e1..cd00f039 100644 --- a/src/Backend/AutopilotMonitor.Functions.Tests/RawWireParityTests.cs +++ b/src/Backend/AutopilotMonitor.Functions.Tests/RawWireParityTests.cs @@ -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> at the call site.) diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/TestDenialReporter.cs b/src/Backend/AutopilotMonitor.Functions.Tests/TestDenialReporter.cs new file mode 100644 index 00000000..9cffb33a --- /dev/null +++ b/src/Backend/AutopilotMonitor.Functions.Tests/TestDenialReporter.cs @@ -0,0 +1,19 @@ +using System.Collections.Generic; +using AutopilotMonitor.Functions.Services; + +namespace AutopilotMonitor.Functions.Tests; + +/// +/// Recording stand-in for so middleware tests can build a +/// PolicyEnforcementMiddleware without the ops-event/notification stack and still assert +/// what the deny path would have reported. +/// +internal sealed class RecordingDenialReporter : IPrivilegedDenialReporter +{ + public List Reported { get; } = new(); + + public void Report(PrivilegedDenial denial) + { + lock (Reported) Reported.Add(denial); + } +} diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Raw/AccessProbeFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Raw/AccessProbeFunction.cs new file mode 100644 index 00000000..ef8bf932 --- /dev/null +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Raw/AccessProbeFunction.cs @@ -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 +{ + /// + /// GET /api/global/raw/access-probe — a GlobalAdminOnly route that does nothing. + /// + /// 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 + /// PrivilegedRouteDenied ops event, would never learn about the probe. The MCP therefore + /// fires this route (fire-and-forget, X-MCP-Tool-Name = 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. + /// + /// + public class AccessProbeFunction + { + [Function("GlobalAccessProbe")] + public async Task 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; + } + } +} diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Raw/AppInsightsQueryFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Raw/AppInsightsQueryFunction.cs index 1c39da45..0cb10eaf 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Raw/AppInsightsQueryFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Raw/AppInsightsQueryFunction.cs @@ -32,8 +32,14 @@ namespace AutopilotMonitor.Functions.Functions.Raw /// [Function("QueryBackendLogs")] public async Task 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"); diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Raw/RawGlobalAdminGate.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Raw/RawGlobalAdminGate.cs new file mode 100644 index 00000000..97a8433c --- /dev/null +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Raw/RawGlobalAdminGate.cs @@ -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 +{ + /// + /// DEFENSE IN DEPTH for the raw table/log proxies. The catalog (GlobalAdminOnly) 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 + /// : an empty context (middleware never ran) has + /// IsGlobalAdmin == false, so the gate fails closed by construction. + /// + internal static class RawGlobalAdminGate + { + /// Returns a 403 response to hand back, or null when the caller is a resolved Global Admin. + public static async Task 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; + } + } +} diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Raw/TableQueryFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Raw/TableQueryFunction.cs index 29e0b6f4..0b20f9bb 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Raw/TableQueryFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Raw/TableQueryFunction.cs @@ -39,8 +39,14 @@ namespace AutopilotMonitor.Functions.Functions.Raw /// [Function("ListRawTables")] public async Task 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 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 diff --git a/src/Backend/AutopilotMonitor.Functions/Middleware/PolicyEnforcementMiddleware.cs b/src/Backend/AutopilotMonitor.Functions/Middleware/PolicyEnforcementMiddleware.cs index 9e04a09e..d5437367 100644 --- a/src/Backend/AutopilotMonitor.Functions/Middleware/PolicyEnforcementMiddleware.cs +++ b/src/Backend/AutopilotMonitor.Functions/Middleware/PolicyEnforcementMiddleware.cs @@ -38,19 +38,25 @@ public class PolicyEnforcementMiddleware : IFunctionsWorkerMiddleware private readonly DelegatedAdminService _delegatedAdminService; private readonly TenantMemberRoleResolver _memberRoleResolver; private readonly TenantConfigurationService _tenantConfigService; + private readonly IPrivilegedDenialReporter _denialReporter; + + /// Longest header value that lands in a denial record; anything beyond is caller noise. + private const int MaxDenialHeaderLength = 64; public PolicyEnforcementMiddleware( ILogger 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]; + } + + /// + /// ASSUME-BREACH layer for the Global-Admin-only surface. A 403 on a GlobalAdminOnly 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). + /// + 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"); + } + } + + /// A 403 by an authenticated caller on a GlobalAdminOnly route — the only denial that is reported. + internal static bool IsPrivilegedDenial(PolicyResult result) + => !result.Allowed + && result.Policy == EndpointPolicy.GlobalAdminOnly + && result.StatusCode == (int)HttpStatusCode.Forbidden; + + /// Pure projection of a denial into the record the reporter writes; separated so tests can pin it without an HttpContext. + 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); + /// /// 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; } = ""; + /// + /// Policy tier of the denied catalog entry; null when the route is unregistered (fail-closed deny). + /// Lets single out GlobalAdminOnly denials for the assume-breach report. + /// + 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, }; } diff --git a/src/Backend/AutopilotMonitor.Functions/Program.cs b/src/Backend/AutopilotMonitor.Functions/Program.cs index bcb937c4..cb74b60b 100644 --- a/src/Backend/AutopilotMonitor.Functions/Program.cs +++ b/src/Backend/AutopilotMonitor.Functions/Program.cs @@ -283,6 +283,8 @@ builder.Services.AddSingleton< AutopilotMonitor.Functions.Services.Monitoring.AzurePoisonQueueProbe>(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); +// Assume-breach observer on the policy middleware's deny path (GlobalAdminOnly 403 → Critical ops event). +builder.Services.AddSingleton(); // 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(); diff --git a/src/Backend/AutopilotMonitor.Functions/Security/EndpointAccessPolicyCatalog.cs b/src/Backend/AutopilotMonitor.Functions/Security/EndpointAccessPolicyCatalog.cs index 80e92568..5e84f112 100644 --- a/src/Backend/AutopilotMonitor.Functions/Security/EndpointAccessPolicyCatalog.cs +++ b/src/Backend/AutopilotMonitor.Functions/Security/EndpointAccessPolicyCatalog.cs @@ -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), diff --git a/src/Backend/AutopilotMonitor.Functions/Services/OpsEventService.cs b/src/Backend/AutopilotMonitor.Functions/Services/OpsEventService.cs index cd786ead..903fed44 100644 --- a/src/Backend/AutopilotMonitor.Functions/Services/OpsEventService.cs +++ b/src/Backend/AutopilotMonitor.Functions/Services/OpsEventService.cs @@ -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 }); + /// + /// Assume-breach signal for the Global-Admin-only surface: an authenticated caller was refused + /// (403) on a GlobalAdminOnly route. Fired from the policy middleware's deny path via + /// (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. + /// + 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, + }); + } + /// /// Fired by when a Kill signal was actually SERVED to /// an agent (as opposed to DeviceBlocked/VersionBlocked, which fire when the admin diff --git a/src/Backend/AutopilotMonitor.Functions/Services/PrivilegedDenialReporter.cs b/src/Backend/AutopilotMonitor.Functions/Services/PrivilegedDenialReporter.cs new file mode 100644 index 00000000..4756cda9 --- /dev/null +++ b/src/Backend/AutopilotMonitor.Functions/Services/PrivilegedDenialReporter.cs @@ -0,0 +1,88 @@ +using System; +using System.Threading.Tasks; +using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.Logging; + +namespace AutopilotMonitor.Functions.Services +{ + /// + /// One refused request on a GlobalAdminOnly route, as seen by the policy middleware. + /// Header-derived values (, ) are caller-supplied + /// and already sanitized/truncated by the middleware before they land here. + /// + 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); + + /// + /// 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). + /// + public interface IPrivilegedDenialReporter + { + /// Fire-and-forget; never throws, never blocks the deny response. + void Report(PrivilegedDenial denial); + } + + /// + /// Assume-breach layer for the Global-Admin-only surface: turns a 403 on a GlobalAdminOnly + /// route into a PrivilegedRouteDenied 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. + /// + public sealed class PrivilegedDenialReporter : IPrivilegedDenialReporter + { + /// One event per (caller, path) per hour; the rest stays in the Warning trace. + internal static readonly TimeSpan Throttle = TimeSpan.FromHours(1); + + private readonly OpsEventService _opsEvents; + private readonly IMemoryCache _cache; + private readonly ILogger _logger; + + public PrivilegedDenialReporter(OpsEventService opsEvents, IMemoryCache cache, ILogger 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"); + } + } + } +} diff --git a/src/McpServer/autopilot-monitor-mcp/src/__tests__/diag-session.test.ts b/src/McpServer/autopilot-monitor-mcp/src/__tests__/diag-session.test.ts index 391806b1..d8722f69 100644 --- a/src/McpServer/autopilot-monitor-mcp/src/__tests__/diag-session.test.ts +++ b/src/McpServer/autopilot-monitor-mcp/src/__tests__/diag-session.test.ts @@ -33,7 +33,7 @@ function captureToolHandlers(ga: boolean): Record { 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; } diff --git a/src/McpServer/autopilot-monitor-mcp/src/__tests__/ga-tool-probe.test.ts b/src/McpServer/autopilot-monitor-mcp/src/__tests__/ga-tool-probe.test.ts new file mode 100644 index 00000000..58535716 --- /dev/null +++ b/src/McpServer/autopilot-monitor-mcp/src/__tests__/ga-tool-probe.test.ts @@ -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; +let errorSpy: ReturnType; + +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 { + 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; + 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(); + }); +}); diff --git a/src/McpServer/autopilot-monitor-mcp/src/__tests__/mcp-http.test.ts b/src/McpServer/autopilot-monitor-mcp/src/__tests__/mcp-http.test.ts index 5ea8de62..ec54fdad 100644 --- a/src/McpServer/autopilot-monitor-mcp/src/__tests__/mcp-http.test.ts +++ b/src/McpServer/autopilot-monitor-mcp/src/__tests__/mcp-http.test.ts @@ -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((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; + 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', () => { diff --git a/src/McpServer/autopilot-monitor-mcp/src/__tests__/tools-catalog-order.test.ts b/src/McpServer/autopilot-monitor-mcp/src/__tests__/tools-catalog-order.test.ts index d1333886..7b773553 100644 --- a/src/McpServer/autopilot-monitor-mcp/src/__tests__/tools-catalog-order.test.ts +++ b/src/McpServer/autopilot-monitor-mcp/src/__tests__/tools-catalog-order.test.ts @@ -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. diff --git a/src/McpServer/autopilot-monitor-mcp/src/client.ts b/src/McpServer/autopilot-monitor-mcp/src/client.ts index 23807d64..2f72159a 100644 --- a/src/McpServer/autopilot-monitor-mcp/src/client.ts +++ b/src/McpServer/autopilot-monitor-mcp/src/client.ts @@ -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('@'); diff --git a/src/McpServer/autopilot-monitor-mcp/src/ga-tool-probe.ts b/src/McpServer/autopilot-monitor-mcp/src/ga-tool-probe.ts new file mode 100644 index 00000000..11b84dca --- /dev/null +++ b/src/McpServer/autopilot-monitor-mcp/src/ga-tool-probe.ts @@ -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. + } +} diff --git a/src/McpServer/autopilot-monitor-mcp/src/generated/wire-types.generated.ts b/src/McpServer/autopilot-monitor-mcp/src/generated/wire-types.generated.ts index 0ee2bb37..5d3ed45c 100644 --- a/src/McpServer/autopilot-monitor-mcp/src/generated/wire-types.generated.ts +++ b/src/McpServer/autopilot-monitor-mcp/src/generated/wire-types.generated.ts @@ -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; diff --git a/src/McpServer/autopilot-monitor-mcp/src/generated/wire-vocabularies.generated.ts b/src/McpServer/autopilot-monitor-mcp/src/generated/wire-vocabularies.generated.ts index d2eaafe7..5406dcf3 100644 --- a/src/McpServer/autopilot-monitor-mcp/src/generated/wire-vocabularies.generated.ts +++ b/src/McpServer/autopilot-monitor-mcp/src/generated/wire-vocabularies.generated.ts @@ -100,6 +100,7 @@ export const OPS_EVENT_TYPES = [ "PoisonQueueBacklogHigh", "PoisonQueueBacklogCritical", "ExcessiveSessionEventsAutoActioned", + "PrivilegedRouteDenied", "OffboardingFeedbackReceived", "TenantOffboarded", "TenantOffboardingFailed", diff --git a/src/McpServer/autopilot-monitor-mcp/src/mcp-http.ts b/src/McpServer/autopilot-monitor-mcp/src/mcp-http.ts index 3bf43872..05897a47 100644 --- a/src/McpServer/autopilot-monitor-mcp/src/mcp-http.ts +++ b/src/McpServer/autopilot-monitor-mcp/src/mcp-http.ts @@ -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. diff --git a/src/McpServer/autopilot-monitor-mcp/src/mcp-server-factory.ts b/src/McpServer/autopilot-monitor-mcp/src/mcp-server-factory.ts index de8d4ff4..8c610b66 100644 --- a/src/McpServer/autopilot-monitor-mcp/src/mcp-server-factory.ts +++ b/src/McpServer/autopilot-monitor-mcp/src/mcp-server-factory.ts @@ -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; } diff --git a/src/McpServer/autopilot-monitor-mcp/src/prompts.ts b/src/McpServer/autopilot-monitor-mcp/src/prompts.ts index 9903404b..ddfc7b1b 100644 --- a/src/McpServer/autopilot-monitor-mcp/src/prompts.ts +++ b/src/McpServer/autopilot-monitor-mcp/src/prompts.ts @@ -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' diff --git a/src/McpServer/autopilot-monitor-mcp/src/telemetry.ts b/src/McpServer/autopilot-monitor-mcp/src/telemetry.ts index 5717a39a..0d1fb4da 100644 --- a/src/McpServer/autopilot-monitor-mcp/src/telemetry.ts +++ b/src/McpServer/autopilot-monitor-mcp/src/telemetry.ts @@ -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 { diff --git a/src/McpServer/autopilot-monitor-mcp/src/tools/admin.ts b/src/McpServer/autopilot-monitor-mcp/src/tools/admin.ts index c5b16454..ab50dea4 100644 --- a/src/McpServer/autopilot-monitor-mcp/src/tools/admin.ts +++ b/src/McpServer/autopilot-monitor-mcp/src/tools/admin.ts @@ -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 = 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. diff --git a/src/Shared/AutopilotMonitor.Shared/DataAccess/OpsEventTypes.cs b/src/Shared/AutopilotMonitor.Shared/DataAccess/OpsEventTypes.cs index 11c6effb..465a4015 100644 --- a/src/Shared/AutopilotMonitor.Shared/DataAccess/OpsEventTypes.cs +++ b/src/Shared/AutopilotMonitor.Shared/DataAccess/OpsEventTypes.cs @@ -70,6 +70,12 @@ namespace AutopilotMonitor.Shared.DataAccess public const string PoisonQueueBacklogHigh = "PoisonQueueBacklogHigh"; public const string PoisonQueueBacklogCritical = "PoisonQueueBacklogCritical"; public const string ExcessiveSessionEventsAutoActioned = "ExcessiveSessionEventsAutoActioned"; + /// + /// An authenticated caller without the GlobalAdmin role was refused (403) on a + /// GlobalAdminOnly route — including the MCP probe a non-GA tool call triggers. + /// Critical for callers without any platform role, Warning for a Global Reader. + /// + 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, diff --git a/src/Shared/AutopilotMonitor.Shared/Models/RawApiModels.cs b/src/Shared/AutopilotMonitor.Shared/Models/RawApiModels.cs index b67cb382..5a37cd19 100644 --- a/src/Shared/AutopilotMonitor.Shared/Models/RawApiModels.cs +++ b/src/Shared/AutopilotMonitor.Shared/Models/RawApiModels.cs @@ -56,6 +56,19 @@ namespace AutopilotMonitor.Shared.Models public IReadOnlyList Tables { get; set; } = default!; } + // Declaration order == wire order. + /// + /// 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. + /// + public class AccessProbeResponse : IApiResponse + { + public bool Success { get; set; } + /// Always "GlobalAdmin" — the only role that reaches the body. + public string Role { get; set; } = default!; + } + // Declaration order == wire order. /// /// Success body of GET /api/global/raw/tables/{tableName} (QueryRawTable): raw table rows, diff --git a/src/Web/autopilot-monitor-web/app/admin/components/OpsAlertRulesSection.tsx b/src/Web/autopilot-monitor-web/app/admin/components/OpsAlertRulesSection.tsx index 228d5325..8e5b8c88 100644 --- a/src/Web/autopilot-monitor-web/app/admin/components/OpsAlertRulesSection.tsx +++ b/src/Web/autopilot-monitor-web/app/admin/components/OpsAlertRulesSection.tsx @@ -97,6 +97,11 @@ const OPS_EVENT_TYPES: Record = { // 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 diff --git a/src/Web/autopilot-monitor-web/utils/shared-manifests.generated.ts b/src/Web/autopilot-monitor-web/utils/shared-manifests.generated.ts index 49047f74..dc3d326f 100644 --- a/src/Web/autopilot-monitor-web/utils/shared-manifests.generated.ts +++ b/src/Web/autopilot-monitor-web/utils/shared-manifests.generated.ts @@ -275,6 +275,7 @@ export const SHARED_MANIFEST = { "PoisonQueueBacklogHigh", "PoisonQueueBacklogCritical", "ExcessiveSessionEventsAutoActioned", + "PrivilegedRouteDenied", "OffboardingFeedbackReceived", "TenantOffboarded", "TenantOffboardingFailed", diff --git a/src/Web/autopilot-monitor-web/utils/shared-manifests.json b/src/Web/autopilot-monitor-web/utils/shared-manifests.json index 00fa10bc..5c1120a5 100644 --- a/src/Web/autopilot-monitor-web/utils/shared-manifests.json +++ b/src/Web/autopilot-monitor-web/utils/shared-manifests.json @@ -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.", diff --git a/src/Web/autopilot-monitor-web/utils/wire-types.generated.ts b/src/Web/autopilot-monitor-web/utils/wire-types.generated.ts index 3fb3794b..6ad6a011 100644 --- a/src/Web/autopilot-monitor-web/utils/wire-types.generated.ts +++ b/src/Web/autopilot-monitor-web/utils/wire-types.generated.ts @@ -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;