mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-25 13:10:13 +00:00
9f367cea0c
The delegated rescue treats every GlobalReadOrAdmin + QueryParam/RouteParam route
as delegated-reachable, which had silently opened several platform-OPERATIONAL
global reads to delegated ("MSP") admins. Those should be Global Admin + read-only
Global Reader only - the platform operator's view, not the managed tenant's data.
- global/ops-events -> TenantScoping.None (Consent/Maintenance/Security/Tenant/
Agent/SLA - operator data). Its in-memory FilterByTenant is reframed as a
GA/Reader ?tenantId= drill (correctness, NOT a delegated-isolation boundary)
and extracted internal-static for direct test coverage.
- New EndpointPolicyEntry.ExcludeDelegated flag: the None-equivalent for routes
whose {tenantId} template forces RouteParam (the {tenantId}-convention test
forbids None there). The middleware ANDs !ExcludeDelegated into the delegated
rescue, so it can only RESTRICT access, never grant it. Applied to:
- global/customs-archive (+ the two drill reads)
- global/tenants/{tenantId}/deletion-manifests
- preview/notification-email/{tenantId}
GA + read-only Global Reader keep all of these.
Full reclassification of every delegated-reachable global/* route: customer
telemetry (sessions / metrics / apps / rules / audit-logs / mcp-usage / ...) stays
delegated-reachable; platform-operational + offboarding reads are now excluded.
Tests: Delegated_{OpsEvents,CustomsArchive,DeletionManifests,PreviewNotificationEmail}_*
+ GlobalReader_*_Allowed + catalog Theory PlatformOperationalReads_ExcludeDelegated.
Backend filtered tests green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
63 lines
2.7 KiB
C#
63 lines
2.7 KiB
C#
using System.Linq;
|
|
using AutopilotMonitor.Functions.Functions.Admin;
|
|
using AutopilotMonitor.Shared.DataAccess;
|
|
using Xunit;
|
|
|
|
namespace AutopilotMonitor.Functions.Tests;
|
|
|
|
/// <summary>
|
|
/// Pins the optional ?tenantId= DRILL on the global ops-events endpoint. ops-events is GA/Reader-only
|
|
/// (catalog TenantScoping.None — a delegated caller cannot reach it; see
|
|
/// PolicyEnforcementMiddlewareTests.Delegated_OpsEvents_WithManagedTenantId_IsForbidden), so this is DRILL
|
|
/// CORRECTNESS, not a security boundary: a GA/Reader that names ?tenantId= must get only that tenant's rows.
|
|
/// OpsEvents is partitioned by category (not tenant), so <see cref="GetOpsEventsFunction.FilterByTenant"/>
|
|
/// is the only thing that scopes the drill — these tests trip a red build if a refactor broadens the
|
|
/// predicate, drops the tenant-less exclusion, or breaks case folding.
|
|
/// </summary>
|
|
public class GetOpsEventsFunctionTests
|
|
{
|
|
private const string TenantA = "11111111-1111-1111-1111-111111111111";
|
|
private const string TenantB = "22222222-2222-2222-2222-222222222222";
|
|
|
|
private static OpsEventEntry Event(string? tenantId, string id = "e") =>
|
|
new() { Id = id, TenantId = tenantId, Category = OpsEventCategory.Security };
|
|
|
|
[Fact]
|
|
public void FilterByTenant_NullOrEmptyFilter_PassesEverything()
|
|
{
|
|
// GA/Reader cross-tenant view: no tenantId named → every tenant's row is visible (unbounded).
|
|
var source = new[] { Event(TenantA), Event(TenantB), Event(tenantId: null) };
|
|
|
|
Assert.Equal(3, GetOpsEventsFunction.FilterByTenant(source, null).Count());
|
|
Assert.Equal(3, GetOpsEventsFunction.FilterByTenant(source, "").Count());
|
|
}
|
|
|
|
[Fact]
|
|
public void FilterByTenant_ConcreteFilter_ReturnsOnlyThatTenant_ZeroForeign()
|
|
{
|
|
var source = new[]
|
|
{
|
|
Event(TenantA, "a1"), Event(TenantB, "b1"), Event(TenantA, "a2"),
|
|
Event(tenantId: null, "n1"),
|
|
};
|
|
|
|
var filtered = GetOpsEventsFunction.FilterByTenant(source, TenantA).ToList();
|
|
|
|
Assert.Equal(2, filtered.Count);
|
|
// The drill must return ONLY the named tenant's rows — no foreign or tenant-less row survives.
|
|
Assert.All(filtered, e => Assert.Equal(TenantA, e.TenantId));
|
|
Assert.DoesNotContain(filtered, e => e.TenantId != TenantA);
|
|
}
|
|
|
|
[Fact]
|
|
public void FilterByTenant_IsCaseInsensitive()
|
|
{
|
|
// AllowedTenantIds / route tenantIds are lowercased while stored TenantId casing is not guaranteed.
|
|
var source = new[] { Event(TenantA.ToUpperInvariant()) };
|
|
|
|
var filtered = GetOpsEventsFunction.FilterByTenant(source, TenantA.ToLowerInvariant()).ToList();
|
|
|
|
Assert.Single(filtered);
|
|
}
|
|
}
|