Files
AutopilotMonitor/src/Backend/AutopilotMonitor.Functions.Tests/GetOpsEventsFunctionTests.cs
T
Oliver Kieselbach 9f367cea0c fix(delegated): exclude platform-operational reads from delegated scope (least-privilege)
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>
2026-06-26 23:21:39 +02:00

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);
}
}