mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-24 12:42:38 +00:00
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:
+1
-1
Submodule internal updated: 0b5da6ce1e...621240b3aa
+2
-1
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user